diff --git a/README.md b/README.md index cb127ce4..99994c6d 100644 --- a/README.md +++ b/README.md @@ -6,35 +6,32 @@ VIP Block Data API attribute sourcing animation -The Block Data API is a REST API for retrieving block editor posts structured as JSON data. While primarily designed for use in decoupled WordPress, the Block Data API can be used anywhere you want to represent block markup as structured data. +The Block Data API is an API for retrieving block editor posts structured as JSON data, with integrations for both the official WordPress REST API and WPGraphQL. While primarily designed for use in decoupled WordPress, the Block Data API can be used anywhere you want to represent block markup as structured data. This plugin is currently developed for use on WordPress sites hosted on the VIP Platform. -## Quickstart - -You can get started with the Block Data API in a few steps. - -If you're a customer with WordPress VIP, see [Install on WordPress VIP](#install-on-wordpress-vip). Otherwise, follow these steps to quickstart on any WordPress site: - -1. Install the plugin by adding it to the `plugins/` directory of the site's GitHub repository. We recommend using [git subtree](#install-via-git-subtree) for adding the plugin. -2. Activate the plugin in the "Plugins" screen of the site's WordPress admin dashboard. -3. Make a request to `/wp-json/vip-block-data-api/v1/posts//blocks` (replacing `` with a valid post ID of your site). - -Other installation options, examples, and helpful filters for customizing the API are outlined below. - ## Table of contents - [Installation](#installation) - [Install on WordPress VIP](#install-on-wordpress-vip) - [Install via ZIP file](#install-via-zip-file) - [Plugin activation](#plugin-activation) -- [Usage](#usage) - - [Versioning](#versioning) -- [Block Data API examples](#block-data-api-examples) - - [Example: Basic text blocks: `core/heading` and `core/paragraph`](#example-basic-text-blocks-coreheading-and-coreparagraph) - - [Example: Text attributes in `core/pullquote`](#example-text-attributes-in-corepullquote) - - [Example: Nested blocks in `core/media-text`](#example-nested-blocks-in-coremedia-text) -- [Preact example](#preact-example) +- [APIs](#apis) + - [REST](#rest) + - [Usage](#usage) + - [Versioning](#versioning) + - [Examples](#examples) + - [Example: Basic text blocks: `core/heading` and `core/paragraph`](#example-basic-text-blocks-coreheading-and-coreparagraph) + - [Example: Text attributes in `core/pullquote`](#example-text-attributes-in-corepullquote) + - [Example: Nested blocks in `core/media-text`](#example-nested-blocks-in-coremedia-text) + - [GraphQL](#graphql) + - [Setup](#setup) + - [Usage](#usage-1) + - [Block Attributes](#block-attributes) + - [Example: Simple nested blocks: `core/list` and `core/quote`](#example-simple-nested-blocks-corelist-and-corequote) +- [API Consumption](#api-consumption) + - [Preact](#preact) + - [Block hierarchy reconstruction](#block-hierarchy-reconstruction) - [Limitations](#limitations) - [Client-side blocks](#client-side-blocks) - [Client-side example](#client-side-example) @@ -46,6 +43,8 @@ Other installation options, examples, and helpful filters for customizing the AP - [`include`](#include) - [`exclude`](#exclude) - [Code Filters](#code-filters) + - [GraphQL](#graphql-1) + - [REST](#rest-1) - [`vip_block_data_api__rest_validate_post_id`](#vip_block_data_api__rest_validate_post_id) - [`vip_block_data_api__rest_permission_callback`](#vip_block_data_api__rest_permission_callback) - [`vip_block_data_api__allow_block`](#vip_block_data_api__allow_block) @@ -97,9 +96,17 @@ To activate the installed plugin: ![Plugin activation][media-plugin-activate] -## Usage +## APIs + +The VIP Block Data API plugin provides two types of APIs to use - REST and GraphQL. The Block Data API [uses server-side registered blocks][wordpress-block-metadata-php-registration] to determine block attributes. Refer to the **[Client-side blocks](#client-side-blocks)** section for more information about client-side block support limitations. + +### REST + +There is no extra setup necessary for the REST API. It is ready to use out of the box. + +#### Usage -The VIP Block Data API plugin provides a REST endpoint for reading post block data as JSON. The REST URL is located at: +The REST URL is located at: ```js /wp-json/vip-block-data-api/v1/posts//blocks @@ -114,9 +121,7 @@ Review these [**Filters**](#filters) to learn more about limiting access to the - [`vip_block_data_api__rest_validate_post_id`](#vip_block_data_api__rest_validate_post_id) - [`vip_block_data_api__rest_permission_callback`](#vip_block_data_api__rest_permission_callback) -The Block Data API [uses server-side registered blocks][wordpress-block-metadata-php-registration] to determine block attributes. Refer to the **[Client-side blocks](#client-side-blocks)** section for more information about client-side block support limitations. - -### Versioning +#### Versioning The current REST endpoint uses a `v1` prefix: @@ -126,11 +131,11 @@ The current REST endpoint uses a `v1` prefix: We plan to utilize API versioning to avoid unexpected changes to the plugin. In the event that we make breaking changes to API output, we will add a new endpoint (e.g. `/wp-json/vip-block-data-api/v2/`) with access to new data. Previous versions will remain accessible for backward compatibility. -## Block Data API examples +#### Examples Examples of WordPress block markup and the associated data structure returned by the Block Data API. -### Example: Basic text blocks: `core/heading` and `core/paragraph` +##### Example: Basic text blocks: `core/heading` and `core/paragraph` ![Heading and paragraph block in editor][media-example-heading-paragraph] @@ -178,7 +183,7 @@ Examples of WordPress block markup and the associated data structure returned by --- -### Example: Text attributes in `core/pullquote` +##### Example: Text attributes in `core/pullquote` ![Pullquote block in editor][media-example-pullquote] @@ -221,7 +226,7 @@ Examples of WordPress block markup and the associated data structure returned by --- -### Example: Nested blocks in `core/media-text` +##### Example: Nested blocks in `core/media-text` ![Media-text block containing heading in editor][media-example-media-text] @@ -281,7 +286,251 @@ Examples of WordPress block markup and the associated data structure returned by -## Preact example +### GraphQL + +The GraphQL API requires some setup before it can be it can be used. + +#### Setup + +The Block Data API integrates with **WPGraphQL** to provide a GraphQL API. It is necessary to have [WPGraphQL installed and activated][wpgraphql-install]. + +Once WPGraphQL has been installed and setup, a new field called `blocksData` will be available for post types that provide content, like posts, pages, etc. + +#### Usage + +The `blocksData` field provides block data for post types that support it. Here is an example query: + +```graphQL +query NewQuery { + post(id: "1", idType: DATABASE_ID) { + blocksData { + blocks { + id + name + attributes { + name + value + } + innerBlocks { + name + parentId + id + attributes { + name + value + } + } + } + } + } +} +``` + +Here, the `id` and `parentId` fields are dynamically generated, unique IDs that help to identify parent-child relationships in the `innerBlocks` under a block, in the overall block structure. The resulting `innerBlocks` is a flattened list that can be untangled using the combination of `id` and `parentId` fields. This is helpful in being able to give back a complicated nesting structure, without having any knowledge as to how deep this nesting goes. For more information on recreating `innerBlocks`, see [Block Hierarchy Reconstruction](#block-hierarchy-reconstruction). + +This behaviour can be changed by passing in `flatten: false`. This would give back the same block hierarchy as shown in the block editor, without the `parentId` being set. In addition, the correct depth would need to be requested in the query so that the entire block hierarchy can be given back. By default, `flatten` is set to true and so can be skipped if flattenining the innerBlocks is the intended behaviour. The same query above, would now look like: + +```graphQL +query NewQuery { + post(id: "1", idType: DATABASE_ID) { + blocksData { + blocks(flatten: false) { + id + name + attributes { + name + value + } + innerBlocks { + attributes { + name + value + } + name + id + } + } + } + } +} +``` + +#### Block Attributes + +The attributes of a block in GraphQL are available in a list of `name` / `value` string pairs, e.g. + +```js +"attributes": [ + { + "name": "content", + "value": "This is item 1 in the list", + }, + { + "name": "fontSize", + "value": "small" + } +] +``` + +This is used instead of a key-value structure. This is a trade-off that makes it easy to retrieve block attributes without specifying the the block type ahead of time, but attribute type information is lost. + +#### Example: Simple nested blocks: `core/list` and `core/quote` + +![List and Quote block in editor][media-example-list-quote] + + + + + + + + + + + + +
Block MarkupQueryBlock Data API
+ +```html + +
    +
  • This is item 1 in the list
  • + + + +
  • This is item 2 in the list
  • + +
+ + + +
+ +

This is a paragraph within a quote

+ +
+ +``` + +
+ +```graphQL +query NewQuery { + post(id: "1", idType: DATABASE_ID) { + blocksData { + blocks { + id + name + attributes { + name + value + } + innerBlocks { + name + parentId + id + attributes { + name + value + } + } + } + } + } +} +``` + + + +```json +{ + "data": { + "post": { + "blocksData": { + "blocks": [ + { + "attributes": [ + { + "name": "ordered", + "value": "" + }, + { + "name": "values", + "value": "" + } + ], + "id": "1", + "name": "core/list", + "innerBlocks": [ + { + "id": "2", + "name": "core/list-item", + "parentId": "1", + "attributes": [ + { + "name": "content", + "value": "This is item 1 in the list" + } + ] + }, + { + "id": "3", + "name": "core/list-item", + "parentId": "1", + "attributes": [ + { + "name": "content", + "value": "This is item 2 in the list" + } + ] + } + ] + }, + { + "attributes": [ + { + "name": "value", + "value": "" + }, + { + "name": "citation", + "value": "" + } + ], + "id": "4", + "name": "core/quote", + "innerBlocks": [ + { + "id": "5", + "name": "core/paragraph", + "parentId": "4", + "attributes": [ + { + "name": "content", + "value": "This is a paragraph within a quote" + }, + { + "name": "dropCap", + "value": "" + } + ] + } + ] + } + ] + } + } + } +} +``` + +
+ +Note that `id` values returned from GraphQL will be alpha-numeric strings, e.g. `"id": "SUQ6MQ=="` and not integers. + +## API Consumption + +### Preact An example [Preact app][preact] app that queries for block data and maps it into customized components. @@ -381,6 +630,63 @@ The code above produces this HTML from post data: ``` +### Block hierarchy reconstruction + +The purpose of this function is to take the flattened `innerBlocks` list under each root block, and reconstruct the block hierarchy. + +The logic is as follows: + +1. Loop through each block. +2. Loop through each block's `innerBlocks`: + * For each `innerBlock`, check if the `parentId` matches the `id` of the root block. + * If yes, add that `innerBlock` to a new list. + * If no, go over the newly constructed list and repeat step 2's logic as the block could be nested under another `innerBlock`. + +This logic has been split over two functions, with the core logic (steps 1, 2a, 2b) being in the function below and the recursive case (2c) being handled in the second function called `convertInnerBlocksToHierarchy`. + +```js +const blocks = payload.data?.post?.blocksData?.blocks ?? []; + +// Iterate over the blocks. +for (const block of blocks) { + // skip if the innerBlocks are not set. + if (!block.innerBlocks) { + continue; + } + + // Get the innerBlocks. + const innerBlocks = block.innerBlocks; + // Create a new array to store the hierarchy. + let innerBlockHierarchy = []; + // Iterate over the innerBlocks and use the parentID and ID to reconstruct the hierarchy. + for (const innerBlock of innerBlocks) { + // If the innerBlock's parentId matches the block's id, add it to the hierarchy. + if (innerBlock.parentId === block.id) { + innerBlockHierarchy.push(innerBlock); + } else { + // Otherwise, use the recursive function to find the right parent. + convertInnerBlocksToHierarchy(innerBlock, innerBlockHierarchy); + } + } + + // Add the innerBlockHierarchy to the block. + block.innerBlocks = innerBlockHierarchy; +} + +function convertInnerBlocksToHierarchy( innerBlock, innerBlockHierarchy) { + for (const innerBlockParent of innerBlockHierarchy) { + // If the innerBlock's parentId matches the innerBlockParent's id, add it to the hierarchy. + if (innerBlock.parentId === innerBlockParent.id) { + innerBlockParent.innerBlocks = innerBlockParent.innerBlocks || []; + innerBlockParent.innerBlocks.push(innerBlock); + // If the innerBlockParent has innerBlocks, loop over them and add it under it the right parent. + } else if (innerBlockParent.innerBlocks) { + convertInnerBlocksToHierarchy(innerBlock, innerBlockParent.innerBlocks); + } + } +} +``` + ## Limitations ### Client-side blocks @@ -569,7 +875,7 @@ The sourced caption is returned in the Block Data API: } ``` -Because the `caption` property in this example is , it seems possible to print the caption to the page safely (e.g. without using `innerHTML` or React's `dangerouslySetInnerHTML`). However, this is not the case and may result in incorrect rendering. +Because the `caption` property in this example is plaintext, it seems possible to print the caption to the page safely (e.g. without using `innerHTML` or React's `dangerouslySetInnerHTML`). However, this is not the case and may result in incorrect rendering. Attributes with the `html` source like the image block caption attribute above can contain plain-text as well as markup. @@ -754,7 +1060,18 @@ Note that custom block filter rules can also be created in code via [the `vip_bl ## Code Filters -Block Data API filters can be applied to limit access to the REST API and modify the output of parsed blocks. +### GraphQL + +By default, the VIP Block Data API enables GraphQL integration automatically if WPGraphQL is activated. To disable this behavior, use the `vip_block_data_api__is_graphql_enabled` filter: + +```php +// Disable GraphQL integration +add_filter( 'vip_block_data_api__is_graphql_enabled', '__return_false', 10, 1 ); +``` + +### REST + +These filters can be applied to limit access to the REST API and modify the output of parsed blocks. ### `vip_block_data_api__rest_validate_post_id` @@ -871,13 +1188,13 @@ Modify or add attributes to a block's output in the Block Data API. /** * Filters a block when parsing is complete. * - * @param array $sourced_block An associative array of parsed block data with keys 'name' and 'attribute'. + * @param array $sourced_block An associative array of parsed block data with keys 'name' and 'attributes'. * @param string $block_name The name of the parsed block, e.g. 'core/paragraph'. * @param string $post_id The post ID associated with the parsed block. * @param string $block The result of parse_blocks() for this block. * Contains 'blockName', 'attrs', 'innerHTML', and 'innerBlocks' keys. */ -$sourced_block = apply_filters( 'vip_block_data_api__sourced_block_result', $sourced_block, $block_name, $post_id, $block ); +$sourced_block = apply_filters( 'vip_block_data_api__sourced_block_result', $sourced_block, $block_name, $post_id, $block); ``` This is useful when block rendering requires attributes stored in post metadata or outside of a block's markup. This filter can be used to add attributes to any core or custom block. For example: @@ -908,7 +1225,7 @@ For another example of how this filter can be used to extend block data, we have The plugin records two data points for analytics, on VIP sites: 1. A usage metric when the `/wp-json/vip-block-data-api` REST API is used to retrive block data. This analytic data simply is a counter, and includes no information about the post's content or metadata. It will only include the customer site ID to associate the usage. - + 2. When an error occurs from within the plugin on the [WordPress VIP][wpvip] platform. This is used to identify issues with customers for private follow-up. Both of these data points are a counter that is incremented, and do not contain any other telemetry or sensitive data. You can see what's being [collected in code here][repo-analytics], and WPVIP's privacy policy [here](https://wpvip.com/privacy/). @@ -997,8 +1314,10 @@ composer run test [media-example-caption-plain]: https://github.com/Automattic/vip-block-data-api/blob/media/example-caption-plain.png [media-example-caption-rich-text]: https://github.com/Automattic/vip-block-data-api/blob/media/example-caption-rich-text.png [media-example-heading-paragraph]: https://github.com/Automattic/vip-block-data-api/blob/media/example-header-paragraph.png +[media-example-list-quote]: https://github.com/Automattic/vip-block-data-api/blob/media/example-utility-quote-list.png [media-example-media-text]: https://github.com/Automattic/vip-block-data-api/blob/media/example-media-text.png [media-example-pullquote]: https://github.com/Automattic/vip-block-data-api/blob/media/example-pullquote.png +[media-example-utility-quote-list]: https://github.com/Automattic/vip-block-data-api/blob/media/example-list-quote.png [media-plugin-activate]: https://github.com/Automattic/vip-block-data-api/blob/media/plugin-activate.png [media-preact-media-text]: https://github.com/Automattic/vip-block-data-api/blob/media/preact-media-text.png [preact]: https://preactjs.com @@ -1019,10 +1338,10 @@ composer run test [wordpress-release-5-0]: https://wordpress.org/documentation/wordpress-version/version-5-0/ [wordpress-rest-api-posts]: https://developer.wordpress.org/rest-api/reference/posts/ [wp-env]: https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/ -[wpvip]: https://wpvip.com/ +[wpgraphql-install]: https://www.wpgraphql.com/docs/quick-start#install [wpvip-mu-plugins-block-data-api]: https://docs.wpvip.com/technical-references/vip-go-mu-plugins/block-data-api-plugin/ [wpvip-page-cache]: https://docs.wpvip.com/technical-references/caching/page-cache/ [wpvip-plugin-activate]: https://docs.wpvip.com/how-tos/activate-plugins-through-code/ [wpvip-plugin-submodules]: https://docs.wpvip.com/technical-references/plugins/installing-plugins-best-practices/#h-submodules [wpvip-plugin-subtrees]: https://docs.wpvip.com/technical-references/plugins/installing-plugins-best-practices/#h-subtrees -[wpvip]: https://wpvip.com/ \ No newline at end of file +[wpvip]: https://wpvip.com/ diff --git a/composer.json b/composer.json index 62eeda39..d78071b8 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,6 @@ "automattic/vipwpcs": "^3.0", "yoast/phpunit-polyfills": "^2.0", "dms/phpunit-arraysubset-asserts": "^0.5.0" - }, "config": { "allow-plugins": { diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 01119deb..54c6441b 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -46,7 +46,7 @@ - + diff --git a/src/graphql/graphql-api.php b/src/graphql/graphql-api.php new file mode 100644 index 00000000..33da566e --- /dev/null +++ b/src/graphql/graphql-api.php @@ -0,0 +1,257 @@ +ID; + $post = get_post( $post_id ); + + $content_parser = new ContentParser(); + + $parser_results = $content_parser->parse( $post->post_content, $post_id ); + + // We need to not return a WP_Error object, and so a regular exception is returned. + if ( is_wp_error( $parser_results ) ) { + Analytics::record_error( $parser_results ); + + // Return API-safe error with extra data (e.g. stack trace) removed. + return new \Exception( $parser_results->get_error_message() ); + } + + $parser_results['blocks'] = array_map( function ( $block ) use ( $post_id ) { + return self::transform_block_format( $block, $post_id ); + }, $parser_results['blocks'] ); + + return $parser_results; + } + + /** + * Transform the block's format to the format expected by the graphQL API. + * + * @param array $block An associative array of parsed block data with keys 'name' and 'attributes'. + * @param array $post_id The associated post ID for the content being transformed. Used to produce unique block IDs. + * + * @return array + */ + public static function transform_block_format( $block, $post_id ) { + // Generate a unique ID for the block. + $block['id'] = Relay::toGlobalId( 'BlockData', sprintf( '%d:%d', $post_id, wp_unique_id() ) ); + + // Convert the attributes to be in the name-value format that the schema expects. + $block = self::map_attributes( $block ); + + if ( isset( $block['innerBlocks'] ) && ! empty( $block['innerBlocks'] ) ) { + $block['innerBlocks'] = array_map( function ( $block ) use ( $post_id ) { + return self::transform_block_format( $block, $post_id ); + }, $block['innerBlocks'] ); + } + + return $block; + } + + /** + * Convert the attributes to be in the name-value format that the schema expects. + * + * @param array $block An associative array of parsed block data with keys 'name' and 'attributes'. + * + * @return array + */ + public static function map_attributes( $block ) { + // check if type of attributes is stdClass and unset it as that's not supported by graphQL. + if ( isset( $block['attributes'] ) && is_object( $block['attributes'] ) ) { + unset( $block['attributes'] ); + } elseif ( isset( $block['attributes'] ) && ! empty( $block['attributes'] ) ) { + $block['attributes'] = array_map( + function ( $name, $value ) { + return [ + 'name' => $name, + 'value' => strval( $value ), + ]; + }, + array_keys( $block['attributes'] ), + array_values( $block['attributes'] ) + ); + } + + return $block; + } + + /** + * Flatten the inner blocks, no matter how many levels of nesting is there. + * + * @param array $inner_blocks the inner blocks in the block. + * @param string $parent_id ID of the parent block, that the inner blocks belong to. + * @param array $flattened_blocks the flattened blocks that's built up as we go through the inner blocks. + * + * @return array + */ + public static function flatten_inner_blocks( $inner_blocks, $parent_id, $flattened_blocks = [] ) { + foreach ( $inner_blocks as $inner_block ) { + // Set the parentId to be the ID of the parent block whose inner blocks are being flattened. + $inner_block['parentId'] = $parent_id; + + if ( ! isset( $inner_block['innerBlocks'] ) ) { + // This block doesnt have any inner blocks, so just add it to the flattened blocks. + array_push( $flattened_blocks, $inner_block ); + } else { + // This block is has inner blocks, so go through the inner blocks recursively. + $inner_blocks_copy = $inner_block['innerBlocks']; + unset( $inner_block['innerBlocks'] ); + + // First add the current block to the flattened blocks, and then go through the inner blocks recursively. + array_push( $flattened_blocks, $inner_block ); + $flattened_blocks = self::flatten_inner_blocks( $inner_blocks_copy, $inner_block['id'], $flattened_blocks ); + } + } + + return $flattened_blocks; + } + + /** + * Register types and fields graphql integration. + * + * @return void + */ + public static function register_types() { + /** + * Filter to enable/disable the graphQL API. By default, it is enabled. + * + * @param bool $is_graphql_to_be_enabled Whether the graphQL API should be enabled or not. + */ + $is_graphql_to_be_enabled = apply_filters( 'vip_block_data_api__is_graphql_enabled', true ); + + if ( ! $is_graphql_to_be_enabled ) { + return; + } + + // Register the type corresponding to the attributes of each individual block. + register_graphql_object_type( + 'BlockAttribute', + [ + 'description' => 'Block attribute', + 'fields' => [ + 'name' => [ + 'type' => [ 'non_null' => 'String' ], + 'description' => 'Block data attribute name', + ], + 'value' => [ + 'type' => [ 'non_null' => 'String' ], + 'description' => 'Block data attribute value', + ], + ], + ], + ); + + // Register the type corresponding to the individual block, with the above attribute. + register_graphql_type( + 'BlockData', + [ + 'description' => 'Block data', + 'fields' => [ + 'id' => [ + 'type' => [ 'non_null' => 'ID' ], + 'description' => 'ID of the block', + ], + 'parentId' => [ + 'type' => 'ID', + 'description' => 'ID of the parent for this inner block, if it is an inner block. Otherwise, it will be null.', + ], + 'name' => [ + 'type' => [ 'non_null' => 'String' ], + 'description' => 'Block name', + ], + 'attributes' => [ + 'type' => [ + 'list_of' => 'BlockAttribute', + ], + 'description' => 'Block attributes', + ], + 'innerBlocks' => [ + 'type' => [ 'list_of' => 'BlockData' ], + 'description' => 'Flattened list of inner blocks of this block', + ], + ], + ], + ); + + // Register the type corresponding to the list of individual blocks, with each item being the above type. + register_graphql_type( + 'BlocksData', + [ + 'description' => 'Data for all the blocks', + 'fields' => [ + 'blocks' => [ + 'type' => [ 'list_of' => 'BlockData' ], + 'description' => 'List of blocks data', + 'args' => [ + 'flatten' => [ + 'type' => 'Boolean', + 'description' => 'Collate the inner blocks under each root block into a single list with a parent-child relationship. This is set to true by default, and setting it to false will preserve the original block hierarchy, but will require nested inner block queries to the desired depth. Default: true', + ], + ], + 'resolve' => function ( $blocks, $args ) { + if ( ! isset( $args['flatten'] ) || true === $args['flatten'] ) { + $blocks['blocks'] = array_map( function ( $block ) { + // Flatten the inner blocks, if any. + if ( isset( $block['innerBlocks'] ) ) { + $block['innerBlocks'] = self::flatten_inner_blocks( $block['innerBlocks'], $block['id'] ); + } + + return $block; + }, $blocks['blocks'] ); + } + + return $blocks['blocks']; + }, + ], + 'warnings' => [ + 'type' => [ 'list_of' => 'String' ], + 'description' => 'List of warnings related to processing the blocks data', + ], + ], + ], + ); + + // Register the field on every post type that supports 'editor'. + register_graphql_field( + 'NodeWithContentEditor', + 'blocksData', + [ + 'type' => 'BlocksData', + 'description' => 'A block representation of post content', + 'resolve' => [ __CLASS__, 'get_blocks_data' ], + ] + ); + } +} + +GraphQLApi::init(); diff --git a/src/parser/block-additions/core-image.php b/src/parser/block-additions/core-image.php index 585906fb..f4442488 100644 --- a/src/parser/block-additions/core-image.php +++ b/src/parser/block-additions/core-image.php @@ -19,22 +19,20 @@ class CoreImage { * @access private */ public static function init() { - add_filter( 'vip_block_data_api__sourced_block_result', [ __CLASS__, 'add_image_metadata' ], 5, 4 ); + add_filter( 'vip_block_data_api__sourced_block_result', [ __CLASS__, 'add_image_metadata' ], 5, 2 ); } /** * Add size metadata to core/image blocks * - * @param array $sourced_block Sourced block result. - * @param string $block_name Name of the block. - * @param int|null $post_id Id of the post. - * @param array $block Block being parsed. + * @param array $sourced_block Sourced block result. + * @param string $block_name Name of the block. * * @access private * * @return array Updated sourced block with new metadata information */ - public static function add_image_metadata( $sourced_block, $block_name, $post_id, $block ) { // phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + public static function add_image_metadata( $sourced_block, $block_name ) { if ( 'core/image' !== $block_name ) { return $sourced_block; } diff --git a/src/parser/content-parser.php b/src/parser/content-parser.php index 0b3cbf21..28af8a87 100644 --- a/src/parser/content-parser.php +++ b/src/parser/content-parser.php @@ -105,6 +105,8 @@ public function should_block_be_included( $block, $block_name, $filter_options ) * @return array|WP_Error */ public function parse( $post_content, $post_id = null, $filter_options = [] ) { + Analytics::record_usage(); + if ( isset( $filter_options['exclude'] ) && isset( $filter_options['include'] ) ) { return new WP_Error( 'vip-block-data-api-invalid-params', 'Cannot provide blocks to exclude and include at the same time', [ 'status' => 400 ] ); } diff --git a/src/rest/rest-api.php b/src/rest/rest-api.php index f5b97795..a60142be 100644 --- a/src/rest/rest-api.php +++ b/src/rest/rest-api.php @@ -128,8 +128,6 @@ public static function get_block_content( $params ) { $post_id = $params['id']; $post = get_post( $post_id ); - Analytics::record_usage(); - $parse_time_start = microtime( true ); $content_parser = new ContentParser(); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 352e2466..f671ea52 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -45,3 +45,5 @@ function _manually_load_plugin() { // Add custom test classes require_once __DIR__ . '/registry-test-case.php'; + +require_once __DIR__ . '/mocks/graphql-relay-mock.php'; diff --git a/tests/graphql/test-graphql-api.php b/tests/graphql/test-graphql-api.php new file mode 100644 index 00000000..06838370 --- /dev/null +++ b/tests/graphql/test-graphql-api.php @@ -0,0 +1,295 @@ +assertTrue( apply_filters( 'vip_block_data_api__is_graphql_enabled', true ) ); + } + + public function test_is_graphql_enabled_false() { + $is_graphql_enabled_function = function () { + return false; + }; + add_filter( 'vip_block_data_api__is_graphql_enabled', $is_graphql_enabled_function, 10, 0 ); + $this->assertFalse( apply_filters( 'vip_block_data_api__is_graphql_enabled', true ) ); + remove_filter( 'vip_block_data_api__is_graphql_enabled', $is_graphql_enabled_function, 10, 0 ); + } + + public function test_get_blocks_data() { + $html = ' + +

Welcome to WordPress. This is your first post. Edit or delete it, then start writing!

+ + + +
+

This is a heading inside a quote

+ + + +
+

This is a heading

+
+
+ + '; + + $expected_blocks = [ + 'blocks' => [ + [ + 'name' => 'core/paragraph', + 'attributes' => [ + array( + 'name' => 'content', + 'value' => 'Welcome to WordPress. This is your first post. Edit or delete it, then start writing!', + ), + array( + 'name' => 'dropCap', + 'value' => '', + ), + ], + 'id' => '1', + ], + [ + 'name' => 'core/quote', + 'attributes' => [ + array( + 'name' => 'value', + 'value' => '', + ), + array( + 'name' => 'citation', + 'value' => '', + ), + ], + 'innerBlocks' => [ + [ + 'name' => 'core/paragraph', + 'attributes' => [ + array( + 'name' => 'content', + 'value' => 'This is a heading inside a quote', + ), + array( + 'name' => 'dropCap', + 'value' => '', + ), + ], + 'id' => '3', + ], + [ + 'name' => 'core/quote', + 'attributes' => [ + array( + 'name' => 'value', + 'value' => '', + ), + array( + 'name' => 'citation', + 'value' => '', + ), + ], + 'innerBlocks' => [ + [ + 'name' => 'core/heading', + 'attributes' => [ + array( + 'name' => 'content', + 'value' => 'This is a heading', + ), + array( + 'name' => 'level', + 'value' => '2', + ), + ], + 'id' => '5', + ], + ], + 'id' => '4', + ], + ], + 'id' => '2', + ], + ], + ]; + + $post = $this->factory()->post->create_and_get( [ + 'post_content' => $html, + ] ); + + $blocks_data = GraphQLApi::get_blocks_data( $post ); + + $this->assertEquals( $expected_blocks, $blocks_data ); + } + + public function test_flatten_inner_blocks() { + $inner_blocks = [ + [ + 'name' => 'core/paragraph', + 'attributes' => [ + array( + 'name' => 'content', + 'value' => 'Welcome to WordPress. This is your first post. Edit or delete it, then start writing!', + ), + array( + 'name' => 'dropCap', + 'value' => '', + ), + ], + 'id' => '2', + ], + [ + 'name' => 'core/quote', + 'attributes' => [ + array( + 'name' => 'value', + 'value' => '', + ), + array( + 'name' => 'citation', + 'value' => '', + ), + ], + 'innerBlocks' => [ + [ + 'name' => 'core/paragraph', + 'attributes' => [ + array( + 'name' => 'content', + 'value' => 'This is a heading inside a quote', + ), + array( + 'name' => 'dropCap', + 'value' => '', + ), + ], + 'id' => '4', + ], + [ + 'name' => 'core/quote', + 'attributes' => [ + array( + 'name' => 'value', + 'value' => '', + ), + array( + 'name' => 'citation', + 'value' => '', + ), + ], + 'innerBlocks' => [ + [ + 'name' => 'core/heading', + 'attributes' => [ + array( + 'name' => 'content', + 'value' => 'This is a heading', + ), + array( + 'name' => 'level', + 'value' => '2', + ), + ], + 'id' => '6', + ], + ], + 'id' => '5', + ], + ], + 'id' => '3', + ], + ]; + + $expected_blocks = [ + [ + 'name' => 'core/paragraph', + 'attributes' => [ + array( + 'name' => 'content', + 'value' => 'Welcome to WordPress. This is your first post. Edit or delete it, then start writing!', + ), + array( + 'name' => 'dropCap', + 'value' => '', + ), + ], + 'parentId' => '1', + 'id' => '2', + ], + [ + 'name' => 'core/quote', + 'attributes' => [ + array( + 'name' => 'value', + 'value' => '', + ), + array( + 'name' => 'citation', + 'value' => '', + ), + ], + 'id' => '3', + 'parentId' => '1', + ], + [ + 'name' => 'core/paragraph', + 'attributes' => [ + array( + 'name' => 'content', + 'value' => 'This is a heading inside a quote', + ), + array( + 'name' => 'dropCap', + 'value' => '', + ), + ], + 'id' => '4', + 'parentId' => '3', + ], + [ + 'name' => 'core/quote', + 'attributes' => [ + array( + 'name' => 'value', + 'value' => '', + ), + array( + 'name' => 'citation', + 'value' => '', + ), + ], + 'id' => '5', + 'parentId' => '3', + ], + [ + 'name' => 'core/heading', + 'attributes' => [ + array( + 'name' => 'content', + 'value' => 'This is a heading', + ), + array( + 'name' => 'level', + 'value' => '2', + ), + ], + 'id' => '6', + 'parentId' => '5', + ], + ]; + + $flattened_blocks = GraphQLApi::flatten_inner_blocks( $inner_blocks, '1' ); + + $this->assertEquals( $expected_blocks, $flattened_blocks ); + } +} diff --git a/tests/mocks/graphql-relay-mock.php b/tests/mocks/graphql-relay-mock.php new file mode 100644 index 00000000..d0b6eabb --- /dev/null +++ b/tests/mocks/graphql-relay-mock.php @@ -0,0 +1,24 @@ +register(true); - $filesToLoad = \Composer\Autoload\ComposerStaticInit5a91735b735b03b013d5e95913d80f8f::$files; + $filesToLoad = \Composer\Autoload\ComposerStaticInit0e98fdf7ca952c8f4cb3c82e525d431a::$files; $requireFile = \Closure::bind(static function ($fileIdentifier, $file) { if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) { $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true; diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php index 480f01b3..f66e5e9d 100644 --- a/vendor/composer/autoload_static.php +++ b/vendor/composer/autoload_static.php @@ -4,7 +4,7 @@ namespace Composer\Autoload; -class ComposerStaticInit5a91735b735b03b013d5e95913d80f8f +class ComposerStaticInit0e98fdf7ca952c8f4cb3c82e525d431a { public static $files = array ( 'a4a119a56e50fbb293281d9a48007e0e' => __DIR__ . '/..' . '/symfony/polyfill-php80/bootstrap.php', @@ -67,9 +67,9 @@ class ComposerStaticInit5a91735b735b03b013d5e95913d80f8f public static function getInitializer(ClassLoader $loader) { return \Closure::bind(function () use ($loader) { - $loader->prefixLengthsPsr4 = ComposerStaticInit5a91735b735b03b013d5e95913d80f8f::$prefixLengthsPsr4; - $loader->prefixDirsPsr4 = ComposerStaticInit5a91735b735b03b013d5e95913d80f8f::$prefixDirsPsr4; - $loader->classMap = ComposerStaticInit5a91735b735b03b013d5e95913d80f8f::$classMap; + $loader->prefixLengthsPsr4 = ComposerStaticInit0e98fdf7ca952c8f4cb3c82e525d431a::$prefixLengthsPsr4; + $loader->prefixDirsPsr4 = ComposerStaticInit0e98fdf7ca952c8f4cb3c82e525d431a::$prefixDirsPsr4; + $loader->classMap = ComposerStaticInit0e98fdf7ca952c8f4cb3c82e525d431a::$classMap; }, null, ClassLoader::class); } diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php index a5c18f11..87d59b63 100644 --- a/vendor/composer/installed.php +++ b/vendor/composer/installed.php @@ -3,7 +3,7 @@ 'name' => 'automattic/vip-block-data-api', 'pretty_version' => 'dev-trunk', 'version' => 'dev-trunk', - 'reference' => '3b1bb5bd0dc9edf8960335e8d0e06ba83930868d', + 'reference' => 'd98425acb0b1c58f3584f1fbc160a4756e0c5c79', 'type' => 'wordpress-plugin', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -13,7 +13,7 @@ 'automattic/vip-block-data-api' => array( 'pretty_version' => 'dev-trunk', 'version' => 'dev-trunk', - 'reference' => '3b1bb5bd0dc9edf8960335e8d0e06ba83930868d', + 'reference' => 'd98425acb0b1c58f3584f1fbc160a4756e0c5c79', 'type' => 'wordpress-plugin', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), diff --git a/vip-block-data-api.php b/vip-block-data-api.php index 94ea06f2..630a25c9 100644 --- a/vip-block-data-api.php +++ b/vip-block-data-api.php @@ -5,9 +5,9 @@ * Description: Access Gutenberg block data in JSON via the REST API. * Author: WordPress VIP * Text Domain: vip-block-data-api - * Version: 1.0.3 - * Requires at least: 5.6.0 - * Tested up to: 6.3.0 + * Version: 1.1.0 + * Requires at least: 5.9 + * Tested up to: 6.3 * Requires PHP: 7.4 * License: GPL-3 * License URI: https://www.gnu.org/licenses/gpl-3.0.html @@ -20,7 +20,7 @@ if ( ! defined( 'VIP_BLOCK_DATA_API_LOADED' ) ) { define( 'VIP_BLOCK_DATA_API_LOADED', true ); - define( 'WPCOMVIP__BLOCK_DATA_API__PLUGIN_VERSION', '1.0.3' ); + define( 'WPCOMVIP__BLOCK_DATA_API__PLUGIN_VERSION', '1.1.0' ); define( 'WPCOMVIP__BLOCK_DATA_API__REST_ROUTE', 'vip-block-data-api/v1' ); // Analytics related configs. @@ -31,6 +31,9 @@ // Composer dependencies. require_once __DIR__ . '/vendor/autoload.php'; + // GraphQL API. + require_once __DIR__ . '/src/graphql/graphql-api.php'; + // /wp-json/ API. require_once __DIR__ . '/src/rest/rest-api.php';