From f401120f66a27c16a6fe38274f797eadc3a25e48 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 8 Nov 2026 11:11:29 -0500 Subject: [PATCH 1/2] REST API: Add parsed blocks to the post content field. Add `content.blocks` to post type responses, holding the post's blocks as parsed from the content, with keys matching the public properties of `WP_Block`. Like `content.raw`, the blocks carry raw stored markup, so they are only returned in the `edit` context. Parsing adds to every response, so they are also opt-in: the request has to name `content.blocks` in `_fields`. See #53603. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018vwYjWL8VuAH7kt7zZdZi4 --- .../class-wp-rest-posts-controller.php | 81 +++ .../tests/rest-api/rest-posts-controller.php | 110 ++++ tests/qunit/fixtures/wp-api-generated.js | 540 ++++++++++++++++++ 3 files changed, 731 insertions(+) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php index 0c24925ff9245..9dda46bfc8343 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php @@ -2012,6 +2012,16 @@ public function prepare_item_for_response( $item, $request ) { $data['content']['block_version'] = block_version( $post->post_content ); } + // Parsing adds to every response, so blocks are only returned when requested by name. + if ( + 'edit' === $request['context'] && + rest_is_field_included( 'content.blocks', $fields ) && + ( is_string( $request['_fields'] ) || is_array( $request['_fields'] ) ) && + in_array( 'content.blocks', wp_parse_list( $request['_fields'] ), true ) + ) { + $data['content']['blocks'] = $this->prepare_blocks_for_response( parse_blocks( $post->post_content ) ); + } + if ( rest_is_field_included( 'excerpt', $fields ) ) { if ( isset( $request['excerpt_length'] ) ) { $excerpt_length = $request['excerpt_length']; @@ -2213,6 +2223,44 @@ public function protected_title_format() { return '%s'; } + /** + * Converts parsed blocks to the shape returned in `content.blocks`. + * + * Keys match the public properties of `WP_Block`. Whitespace-only freeform + * blocks between top-level blocks are dropped. + * + * @since 7.3.0 + * + * @param array $parsed_blocks Blocks as returned by `parse_blocks()`. + * @return list> Block data. + */ + protected function prepare_blocks_for_response( array $parsed_blocks ): array { + $blocks = array(); + + foreach ( $parsed_blocks as $parsed_block ) { + if ( ! is_array( $parsed_block ) ) { + continue; + } + + $name = isset( $parsed_block['blockName'] ) && is_string( $parsed_block['blockName'] ) ? $parsed_block['blockName'] : null; + $inner_html = isset( $parsed_block['innerHTML'] ) && is_string( $parsed_block['innerHTML'] ) ? $parsed_block['innerHTML'] : ''; + + if ( null === $name && '' === trim( $inner_html ) ) { + continue; + } + + $blocks[] = array( + 'name' => $name, + 'attributes' => (object) ( isset( $parsed_block['attrs'] ) && is_array( $parsed_block['attrs'] ) ? $parsed_block['attrs'] : array() ), + 'inner_blocks' => $this->prepare_blocks_for_response( isset( $parsed_block['innerBlocks'] ) && is_array( $parsed_block['innerBlocks'] ) ? $parsed_block['innerBlocks'] : array() ), + 'inner_html' => $inner_html, + 'inner_content' => isset( $parsed_block['innerContent'] ) && is_array( $parsed_block['innerContent'] ) ? $parsed_block['innerContent'] : array(), + ); + } + + return $blocks; + } + /** * Prepares links for the request. * @@ -2668,6 +2716,39 @@ public function get_item_schema() { 'context' => array( 'edit' ), 'readonly' => true, ), + 'blocks' => array( + 'description' => __( 'Blocks parsed from the content. Only returned when requested with _fields.' ), + 'type' => 'array', + 'context' => array( 'edit' ), + 'readonly' => true, + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'description' => __( 'Block name, or null for freeform content.' ), + 'type' => array( 'string', 'null' ), + ), + 'attributes' => array( + 'description' => __( 'Attributes stored in the block delimiter.' ), + 'type' => 'object', + ), + 'inner_blocks' => array( + 'description' => __( 'Nested blocks, in the same shape.' ), + 'type' => 'array', + 'items' => array( 'type' => 'object' ), + ), + 'inner_html' => array( + 'description' => __( 'Block HTML without its inner blocks.' ), + 'type' => 'string', + ), + 'inner_content' => array( + 'description' => __( 'HTML chunks, with null where each inner block goes.' ), + 'type' => 'array', + 'items' => array( 'type' => array( 'string', 'null' ) ), + ), + ), + ), + ), 'protected' => array( 'description' => __( 'Whether the content is protected with a password.' ), 'type' => 'boolean', diff --git a/tests/phpunit/tests/rest-api/rest-posts-controller.php b/tests/phpunit/tests/rest-api/rest-posts-controller.php index 7f4f09d78b308..7fd5af3708fb2 100644 --- a/tests/phpunit/tests/rest-api/rest-posts-controller.php +++ b/tests/phpunit/tests/rest-api/rest-posts-controller.php @@ -2622,6 +2622,116 @@ public function test_get_post_should_have_block_version_indicate_no_block_conten $this->assertSame( 0, $data['content']['block_version'] ); } + /** + * Requests a post with the given query parameters as an editor. + * + * @param array $params Query parameters. + * @return array The `content` field of the response. + */ + private function get_content_for_blocks_test( array $params ): array { + $editor_id = self::$editor_id; + $this->assertIsInt( $editor_id ); + wp_set_current_user( $editor_id ); + + $post_id = self::factory()->post->create( + array( + 'post_content' => "\n

Hello

\n\n\n\n
\n

Inner

\n
\n", + ) + ); + + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/posts/%d', $post_id ) ); + $request->set_query_params( $params ); + $response = rest_get_server()->dispatch( $request ); + $data = rest_get_server()->response_to_data( $response, false ); + $this->assertIsArray( $data ); + $this->assertIsArray( $data['content'] ); + + return $data['content']; + } + + /** + * @ticket 53603 + */ + public function test_get_item_returns_blocks_when_requested_in_edit_context(): void { + $content = $this->get_content_for_blocks_test( + array( + 'context' => 'edit', + '_fields' => 'content.blocks', + ) + ); + + $keys = array( 'name', 'attributes', 'inner_blocks', 'inner_html', 'inner_content' ); + $blocks = $content['blocks']; + $this->assertIsArray( $blocks ); + $this->assertCount( 2, $blocks, 'Whitespace between blocks should be dropped.' ); + $this->assertIsArray( $blocks[0] ); + $this->assertIsArray( $blocks[1] ); + $this->assertSame( $keys, array_keys( $blocks[0] ) ); + $this->assertSame( 'core/paragraph', $blocks[0]['name'] ); + $this->assertEquals( (object) array( 'align' => 'center' ), $blocks[0]['attributes'] ); + $this->assertSame( 'core/group', $blocks[1]['name'] ); + $this->assertEquals( new stdClass(), $blocks[1]['attributes'], 'Empty attributes should encode as a JSON object.' ); + + $inner_blocks = $blocks[1]['inner_blocks']; + $this->assertIsArray( $inner_blocks ); + $this->assertCount( 1, $inner_blocks ); + $this->assertIsArray( $inner_blocks[0] ); + $this->assertSame( $keys, array_keys( $inner_blocks[0] ) ); + $this->assertSame( 'core/paragraph', $inner_blocks[0]['name'] ); + } + + /** + * @ticket 53603 + */ + public function test_get_item_does_not_return_blocks_unless_requested(): void { + $content = $this->get_content_for_blocks_test( array( 'context' => 'edit' ) ); + + $this->assertArrayNotHasKey( 'blocks', $content ); + } + + /** + * @ticket 53603 + */ + public function test_get_item_does_not_return_blocks_for_content_field(): void { + $content = $this->get_content_for_blocks_test( + array( + 'context' => 'edit', + '_fields' => 'content', + ) + ); + + $this->assertArrayNotHasKey( 'blocks', $content ); + } + + /** + * @ticket 53603 + */ + public function test_get_item_does_not_return_blocks_in_view_context(): void { + $content = $this->get_content_for_blocks_test( array( '_fields' => 'content.blocks' ) ); + + $this->assertArrayNotHasKey( 'blocks', $content ); + } + + /** + * @ticket 53603 + */ + public function test_get_item_schema_includes_content_blocks(): void { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/posts' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertIsArray( $data['schema'] ); + $this->assertIsArray( $data['schema']['properties'] ); + $this->assertIsArray( $data['schema']['properties']['content'] ); + $this->assertIsArray( $data['schema']['properties']['content']['properties'] ); + $properties = $data['schema']['properties']['content']['properties']; + + $this->assertArrayHasKey( 'blocks', $properties ); + $this->assertIsArray( $properties['blocks'] ); + $this->assertSame( array( 'edit' ), $properties['blocks']['context'] ); + $this->assertTrue( $properties['blocks']['readonly'] ); + } + public function test_get_item_read_permission_custom_post_status_not_authenticated() { register_post_status( 'testpubstatus', array( 'public' => true ) ); register_post_status( 'testprivtatus', array( 'public' => false ) ); diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 807eda9d2cf92..12f4fbae7662d 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -760,6 +760,51 @@ mockedApiResponse.Schema = { ], "readonly": true }, + "blocks": { + "description": "Blocks parsed from the content. Only returned when requested with _fields.", + "type": "array", + "context": [ + "edit" + ], + "readonly": true, + "items": { + "type": "object", + "properties": { + "name": { + "description": "Block name, or null for freeform content.", + "type": [ + "string", + "null" + ] + }, + "attributes": { + "description": "Attributes stored in the block delimiter.", + "type": "object" + }, + "inner_blocks": { + "description": "Nested blocks, in the same shape.", + "type": "array", + "items": { + "type": "object" + } + }, + "inner_html": { + "description": "Block HTML without its inner blocks.", + "type": "string" + }, + "inner_content": { + "description": "HTML chunks, with null where each inner block goes.", + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, "protected": { "description": "Whether the content is protected with a password.", "type": "boolean", @@ -1044,6 +1089,51 @@ mockedApiResponse.Schema = { ], "readonly": true }, + "blocks": { + "description": "Blocks parsed from the content. Only returned when requested with _fields.", + "type": "array", + "context": [ + "edit" + ], + "readonly": true, + "items": { + "type": "object", + "properties": { + "name": { + "description": "Block name, or null for freeform content.", + "type": [ + "string", + "null" + ] + }, + "attributes": { + "description": "Attributes stored in the block delimiter.", + "type": "object" + }, + "inner_blocks": { + "description": "Nested blocks, in the same shape.", + "type": "array", + "items": { + "type": "object" + } + }, + "inner_html": { + "description": "Block HTML without its inner blocks.", + "type": "string" + }, + "inner_content": { + "description": "HTML chunks, with null where each inner block goes.", + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, "protected": { "description": "Whether the content is protected with a password.", "type": "boolean", @@ -1483,6 +1573,51 @@ mockedApiResponse.Schema = { ], "readonly": true }, + "blocks": { + "description": "Blocks parsed from the content. Only returned when requested with _fields.", + "type": "array", + "context": [ + "edit" + ], + "readonly": true, + "items": { + "type": "object", + "properties": { + "name": { + "description": "Block name, or null for freeform content.", + "type": [ + "string", + "null" + ] + }, + "attributes": { + "description": "Attributes stored in the block delimiter.", + "type": "object" + }, + "inner_blocks": { + "description": "Nested blocks, in the same shape.", + "type": "array", + "items": { + "type": "object" + } + }, + "inner_html": { + "description": "Block HTML without its inner blocks.", + "type": "string" + }, + "inner_content": { + "description": "HTML chunks, with null where each inner block goes.", + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, "protected": { "description": "Whether the content is protected with a password.", "type": "boolean", @@ -1971,6 +2106,51 @@ mockedApiResponse.Schema = { ], "readonly": true }, + "blocks": { + "description": "Blocks parsed from the content. Only returned when requested with _fields.", + "type": "array", + "context": [ + "edit" + ], + "readonly": true, + "items": { + "type": "object", + "properties": { + "name": { + "description": "Block name, or null for freeform content.", + "type": [ + "string", + "null" + ] + }, + "attributes": { + "description": "Attributes stored in the block delimiter.", + "type": "object" + }, + "inner_blocks": { + "description": "Nested blocks, in the same shape.", + "type": "array", + "items": { + "type": "object" + } + }, + "inner_html": { + "description": "Block HTML without its inner blocks.", + "type": "string" + }, + "inner_content": { + "description": "HTML chunks, with null where each inner block goes.", + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, "protected": { "description": "Whether the content is protected with a password.", "type": "boolean", @@ -2227,6 +2407,51 @@ mockedApiResponse.Schema = { ], "readonly": true }, + "blocks": { + "description": "Blocks parsed from the content. Only returned when requested with _fields.", + "type": "array", + "context": [ + "edit" + ], + "readonly": true, + "items": { + "type": "object", + "properties": { + "name": { + "description": "Block name, or null for freeform content.", + "type": [ + "string", + "null" + ] + }, + "attributes": { + "description": "Attributes stored in the block delimiter.", + "type": "object" + }, + "inner_blocks": { + "description": "Nested blocks, in the same shape.", + "type": "array", + "items": { + "type": "object" + } + }, + "inner_html": { + "description": "Block HTML without its inner blocks.", + "type": "string" + }, + "inner_content": { + "description": "HTML chunks, with null where each inner block goes.", + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, "protected": { "description": "Whether the content is protected with a password.", "type": "boolean", @@ -2633,6 +2858,51 @@ mockedApiResponse.Schema = { ], "readonly": true }, + "blocks": { + "description": "Blocks parsed from the content. Only returned when requested with _fields.", + "type": "array", + "context": [ + "edit" + ], + "readonly": true, + "items": { + "type": "object", + "properties": { + "name": { + "description": "Block name, or null for freeform content.", + "type": [ + "string", + "null" + ] + }, + "attributes": { + "description": "Attributes stored in the block delimiter.", + "type": "object" + }, + "inner_blocks": { + "description": "Nested blocks, in the same shape.", + "type": "array", + "items": { + "type": "object" + } + }, + "inner_html": { + "description": "Block HTML without its inner blocks.", + "type": "string" + }, + "inner_content": { + "description": "HTML chunks, with null where each inner block goes.", + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, "protected": { "description": "Whether the content is protected with a password.", "type": "boolean", @@ -4975,6 +5245,51 @@ mockedApiResponse.Schema = { ], "readonly": true }, + "blocks": { + "description": "Blocks parsed from the content. Only returned when requested with _fields.", + "type": "array", + "context": [ + "edit" + ], + "readonly": true, + "items": { + "type": "object", + "properties": { + "name": { + "description": "Block name, or null for freeform content.", + "type": [ + "string", + "null" + ] + }, + "attributes": { + "description": "Attributes stored in the block delimiter.", + "type": "object" + }, + "inner_blocks": { + "description": "Nested blocks, in the same shape.", + "type": "array", + "items": { + "type": "object" + } + }, + "inner_html": { + "description": "Block HTML without its inner blocks.", + "type": "string" + }, + "inner_content": { + "description": "HTML chunks, with null where each inner block goes.", + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, "protected": { "description": "Whether the content is protected with a password.", "type": "boolean", @@ -5189,6 +5504,51 @@ mockedApiResponse.Schema = { ], "readonly": true }, + "blocks": { + "description": "Blocks parsed from the content. Only returned when requested with _fields.", + "type": "array", + "context": [ + "edit" + ], + "readonly": true, + "items": { + "type": "object", + "properties": { + "name": { + "description": "Block name, or null for freeform content.", + "type": [ + "string", + "null" + ] + }, + "attributes": { + "description": "Attributes stored in the block delimiter.", + "type": "object" + }, + "inner_blocks": { + "description": "Nested blocks, in the same shape.", + "type": "array", + "items": { + "type": "object" + } + }, + "inner_html": { + "description": "Block HTML without its inner blocks.", + "type": "string" + }, + "inner_content": { + "description": "HTML chunks, with null where each inner block goes.", + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, "protected": { "description": "Whether the content is protected with a password.", "type": "boolean", @@ -5558,6 +5918,51 @@ mockedApiResponse.Schema = { ], "readonly": true }, + "blocks": { + "description": "Blocks parsed from the content. Only returned when requested with _fields.", + "type": "array", + "context": [ + "edit" + ], + "readonly": true, + "items": { + "type": "object", + "properties": { + "name": { + "description": "Block name, or null for freeform content.", + "type": [ + "string", + "null" + ] + }, + "attributes": { + "description": "Attributes stored in the block delimiter.", + "type": "object" + }, + "inner_blocks": { + "description": "Nested blocks, in the same shape.", + "type": "array", + "items": { + "type": "object" + } + }, + "inner_html": { + "description": "Block HTML without its inner blocks.", + "type": "string" + }, + "inner_content": { + "description": "HTML chunks, with null where each inner block goes.", + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, "protected": { "description": "Whether the content is protected with a password.", "type": "boolean", @@ -7546,6 +7951,51 @@ mockedApiResponse.Schema = { ], "readonly": true }, + "blocks": { + "description": "Blocks parsed from the content. Only returned when requested with _fields.", + "type": "array", + "context": [ + "edit" + ], + "readonly": true, + "items": { + "type": "object", + "properties": { + "name": { + "description": "Block name, or null for freeform content.", + "type": [ + "string", + "null" + ] + }, + "attributes": { + "description": "Attributes stored in the block delimiter.", + "type": "object" + }, + "inner_blocks": { + "description": "Nested blocks, in the same shape.", + "type": "array", + "items": { + "type": "object" + } + }, + "inner_html": { + "description": "Block HTML without its inner blocks.", + "type": "string" + }, + "inner_content": { + "description": "HTML chunks, with null where each inner block goes.", + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, "protected": { "description": "Whether the content is protected with a password.", "type": "boolean", @@ -7727,6 +8177,51 @@ mockedApiResponse.Schema = { ], "readonly": true }, + "blocks": { + "description": "Blocks parsed from the content. Only returned when requested with _fields.", + "type": "array", + "context": [ + "edit" + ], + "readonly": true, + "items": { + "type": "object", + "properties": { + "name": { + "description": "Block name, or null for freeform content.", + "type": [ + "string", + "null" + ] + }, + "attributes": { + "description": "Attributes stored in the block delimiter.", + "type": "object" + }, + "inner_blocks": { + "description": "Nested blocks, in the same shape.", + "type": "array", + "items": { + "type": "object" + } + }, + "inner_html": { + "description": "Block HTML without its inner blocks.", + "type": "string" + }, + "inner_content": { + "description": "HTML chunks, with null where each inner block goes.", + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, "protected": { "description": "Whether the content is protected with a password.", "type": "boolean", @@ -8064,6 +8559,51 @@ mockedApiResponse.Schema = { ], "readonly": true }, + "blocks": { + "description": "Blocks parsed from the content. Only returned when requested with _fields.", + "type": "array", + "context": [ + "edit" + ], + "readonly": true, + "items": { + "type": "object", + "properties": { + "name": { + "description": "Block name, or null for freeform content.", + "type": [ + "string", + "null" + ] + }, + "attributes": { + "description": "Attributes stored in the block delimiter.", + "type": "object" + }, + "inner_blocks": { + "description": "Nested blocks, in the same shape.", + "type": "array", + "items": { + "type": "object" + } + }, + "inner_html": { + "description": "Block HTML without its inner blocks.", + "type": "string" + }, + "inner_content": { + "description": "HTML chunks, with null where each inner block goes.", + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, "protected": { "description": "Whether the content is protected with a password.", "type": "boolean", From 9485f2febcecabceb98319419005bcb99cde76de Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 10 Nov 2026 15:47:01 -0800 Subject: [PATCH 2/2] REST API: Keep content.blocks in step with content.raw. Apply the same Block Hooks to the content before parsing it, so `content.blocks` and `content.raw` describe the same tree, and return `content.blocks` from the revisions and autosaves endpoints, whose schemas already copy the field from the parent post type. Move the block conversion and the request check into private helpers in rest-api.php so both controllers share them. See #53603. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018vwYjV3Gi53g5ct8BDL5JE --- src/wp-includes/rest-api.php | 62 +++++++++ .../class-wp-rest-posts-controller.php | 61 ++------- .../class-wp-rest-revisions-controller.php | 3 + .../rest-api/rest-autosaves-controller.php | 34 +++++ .../tests/rest-api/rest-posts-controller.php | 118 +++++++++++++++++- .../rest-api/rest-revisions-controller.php | 49 ++++++++ 6 files changed, 278 insertions(+), 49 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index f40021f4d06cd..4a5cf3bcbe047 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -3678,3 +3678,65 @@ function wp_is_rest_endpoint() { */ return (bool) apply_filters( 'wp_is_rest_endpoint', $is_rest_endpoint ); } + +/** + * Determines whether a request asks for `content.blocks`. + * + * Blocks hold the raw stored markup, so like `content.raw` they are only + * returned in the `edit` context. Parsing adds to every response, so they + * are also opt-in: the request has to name `content.blocks` in `_fields`. + * + * @since 7.3.0 + * @access private + * + * @param WP_REST_Request $request Request object. + * @param string[] $fields Fields to include in the response. + * @return bool Whether to return `content.blocks`. + */ +function rest_is_content_blocks_requested( WP_REST_Request $request, array $fields ): bool { + $requested = $request['_fields']; + + return 'edit' === $request['context'] && + rest_is_field_included( 'content.blocks', $fields ) && + ( is_string( $requested ) || is_array( $requested ) ) && + in_array( 'content.blocks', wp_parse_list( $requested ), true ); +} + +/** + * Converts parsed blocks to the shape returned in `content.blocks`. + * + * Keys match the public properties of `WP_Block`. Whitespace-only freeform + * blocks between top-level blocks are dropped. + * + * @since 7.3.0 + * @access private + * + * @param array $parsed_blocks Blocks as returned by `parse_blocks()`. + * @return list> Block data. + */ +function rest_prepare_blocks_for_response( array $parsed_blocks ): array { + $blocks = array(); + + foreach ( $parsed_blocks as $parsed_block ) { + if ( ! is_array( $parsed_block ) ) { + continue; + } + + $name = isset( $parsed_block['blockName'] ) && is_string( $parsed_block['blockName'] ) ? $parsed_block['blockName'] : null; + $inner_html = isset( $parsed_block['innerHTML'] ) && is_string( $parsed_block['innerHTML'] ) ? $parsed_block['innerHTML'] : ''; + + if ( null === $name && '' === trim( $inner_html ) ) { + continue; + } + + $blocks[] = array( + 'name' => $name, + 'attributes' => (object) ( isset( $parsed_block['attrs'] ) && is_array( $parsed_block['attrs'] ) ? $parsed_block['attrs'] : array() ), + 'inner_blocks' => rest_prepare_blocks_for_response( isset( $parsed_block['innerBlocks'] ) && is_array( $parsed_block['innerBlocks'] ) ? $parsed_block['innerBlocks'] : array() ), + 'inner_html' => $inner_html, + 'inner_content' => isset( $parsed_block['innerContent'] ) && is_array( $parsed_block['innerContent'] ) ? $parsed_block['innerContent'] : array(), + ); + } + + return $blocks; +} diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php index 9dda46bfc8343..a9f6d344a9652 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php @@ -2012,16 +2012,6 @@ public function prepare_item_for_response( $item, $request ) { $data['content']['block_version'] = block_version( $post->post_content ); } - // Parsing adds to every response, so blocks are only returned when requested by name. - if ( - 'edit' === $request['context'] && - rest_is_field_included( 'content.blocks', $fields ) && - ( is_string( $request['_fields'] ) || is_array( $request['_fields'] ) ) && - in_array( 'content.blocks', wp_parse_list( $request['_fields'] ), true ) - ) { - $data['content']['blocks'] = $this->prepare_blocks_for_response( parse_blocks( $post->post_content ) ); - } - if ( rest_is_field_included( 'excerpt', $fields ) ) { if ( isset( $request['excerpt_length'] ) ) { $excerpt_length = $request['excerpt_length']; @@ -2188,6 +2178,19 @@ public function prepare_item_for_response( $item, $request ) { $response = insert_hooked_blocks_into_rest_response( $response, $post ); } + $data = $response->get_data(); + if ( is_array( $data ) && isset( $data['content'] ) && is_array( $data['content'] ) && rest_is_content_blocks_requested( $request, $fields ) ) { + $content = $post->post_content; + + // Apply the same Block Hooks as `content.raw`, so both describe the same tree. + if ( in_array( $this->post_type, $content_like_post_types, true ) ) { + $content = apply_block_hooks_to_content_from_post_object( $content, $post, 'insert_hooked_blocks_and_set_ignored_hooked_blocks_metadata' ); + } + + $data['content']['blocks'] = rest_prepare_blocks_for_response( parse_blocks( $content ) ); + $response->set_data( $data ); + } + /** * Filters the post data for a REST API response. * @@ -2223,44 +2226,6 @@ public function protected_title_format() { return '%s'; } - /** - * Converts parsed blocks to the shape returned in `content.blocks`. - * - * Keys match the public properties of `WP_Block`. Whitespace-only freeform - * blocks between top-level blocks are dropped. - * - * @since 7.3.0 - * - * @param array $parsed_blocks Blocks as returned by `parse_blocks()`. - * @return list> Block data. - */ - protected function prepare_blocks_for_response( array $parsed_blocks ): array { - $blocks = array(); - - foreach ( $parsed_blocks as $parsed_block ) { - if ( ! is_array( $parsed_block ) ) { - continue; - } - - $name = isset( $parsed_block['blockName'] ) && is_string( $parsed_block['blockName'] ) ? $parsed_block['blockName'] : null; - $inner_html = isset( $parsed_block['innerHTML'] ) && is_string( $parsed_block['innerHTML'] ) ? $parsed_block['innerHTML'] : ''; - - if ( null === $name && '' === trim( $inner_html ) ) { - continue; - } - - $blocks[] = array( - 'name' => $name, - 'attributes' => (object) ( isset( $parsed_block['attrs'] ) && is_array( $parsed_block['attrs'] ) ? $parsed_block['attrs'] : array() ), - 'inner_blocks' => $this->prepare_blocks_for_response( isset( $parsed_block['innerBlocks'] ) && is_array( $parsed_block['innerBlocks'] ) ? $parsed_block['innerBlocks'] : array() ), - 'inner_html' => $inner_html, - 'inner_content' => isset( $parsed_block['innerContent'] ) && is_array( $parsed_block['innerContent'] ) ? $parsed_block['innerContent'] : array(), - ); - } - - return $blocks; - } - /** * Prepares links for the request. * diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php index a68f0cd5a513f..204ce86b9554c 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php @@ -703,6 +703,9 @@ public function prepare_item_for_response( $item, $request ) { /** This filter is documented in wp-includes/post-template.php */ $data['content']['rendered'] = apply_filters( 'the_content', $post->post_content ); } + if ( rest_is_content_blocks_requested( $request, $fields ) ) { + $data['content']['blocks'] = rest_prepare_blocks_for_response( parse_blocks( $post->post_content ) ); + } if ( rest_is_field_included( 'excerpt', $fields ) ) { $data['excerpt'] = array(); diff --git a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php index be6a1996ee98a..00924d3fff51f 100644 --- a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php +++ b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php @@ -1074,4 +1074,38 @@ public static function data_head_request_with_specified_fields_returns_success_r 'get_items request' => array( '/wp/v2/posts/%d' ), ); } + + /** + * @ticket 53603 + */ + public function test_get_item_returns_blocks_when_requested_in_edit_context(): void { + wp_set_current_user( self::$editor_id ); + $autosave_id = wp_create_post_autosave( + array( + 'post_ID' => self::$post_id, + 'post_type' => 'post', + 'post_title' => 'Autosave', + 'post_excerpt' => '', + 'post_content' => '

Autosaved

', + ) + ); + $this->assertIsInt( $autosave_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves/' . $autosave_id ); + $request->set_query_params( + array( + 'context' => 'edit', + '_fields' => 'content.blocks', + ) + ); + $data = rest_get_server()->response_to_data( rest_get_server()->dispatch( $request ), false ); + $this->assertIsArray( $data ); + $this->assertIsArray( $data['content'] ); + $blocks = $data['content']['blocks']; + + $this->assertIsArray( $blocks ); + $this->assertCount( 1, $blocks ); + $this->assertIsArray( $blocks[0] ); + $this->assertSame( '

Autosaved

', $blocks[0]['inner_html'] ); + } } diff --git a/tests/phpunit/tests/rest-api/rest-posts-controller.php b/tests/phpunit/tests/rest-api/rest-posts-controller.php index 7fd5af3708fb2..62d5f5d7728e3 100644 --- a/tests/phpunit/tests/rest-api/rest-posts-controller.php +++ b/tests/phpunit/tests/rest-api/rest-posts-controller.php @@ -2625,7 +2625,7 @@ public function test_get_post_should_have_block_version_indicate_no_block_conten /** * Requests a post with the given query parameters as an editor. * - * @param array $params Query parameters. + * @param array $params Query parameters. * @return array The `content` field of the response. */ private function get_content_for_blocks_test( array $params ): array { @@ -2732,6 +2732,122 @@ public function test_get_item_schema_includes_content_blocks(): void { $this->assertTrue( $properties['blocks']['readonly'] ); } + /** + * @ticket 53603 + */ + public function test_get_item_returns_blocks_for_fields_array(): void { + $content = $this->get_content_for_blocks_test( + array( + 'context' => 'edit', + '_fields' => array( 'id', 'content.blocks' ), + ) + ); + + $this->assertIsArray( $content['blocks'] ); + $this->assertCount( 2, $content['blocks'] ); + } + + /** + * @ticket 53603 + */ + public function test_get_item_returns_freeform_content_as_one_block(): void { + $editor_id = self::$editor_id; + $this->assertIsInt( $editor_id ); + wp_set_current_user( $editor_id ); + + $html = "

Classic

\n

Content

"; + $post_id = self::factory()->post->create( array( 'post_content' => $html ) ); + + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/posts/%d', $post_id ) ); + $request->set_query_params( + array( + 'context' => 'edit', + '_fields' => 'content.blocks', + ) + ); + $data = rest_get_server()->response_to_data( rest_get_server()->dispatch( $request ), false ); + $this->assertIsArray( $data ); + $this->assertIsArray( $data['content'] ); + $blocks = $data['content']['blocks']; + + $this->assertIsArray( $blocks ); + $this->assertCount( 1, $blocks ); + $this->assertIsArray( $blocks[0] ); + $this->assertNull( $blocks[0]['name'] ); + $this->assertSame( $html, $blocks[0]['inner_html'] ); + } + + /** + * @ticket 53603 + */ + public function test_get_item_blocks_include_hooked_blocks_like_raw_content(): void { + register_block_type( + 'tests/hooked-block', + array( + 'block_hooks' => array( 'core/group' => 'after' ), + ) + ); + + $content = $this->get_content_for_blocks_test( + array( + 'context' => 'edit', + '_fields' => 'content.raw,content.blocks', + ) + ); + + unregister_block_type( 'tests/hooked-block' ); + + $this->assertIsString( $content['raw'] ); + $this->assertIsArray( $content['blocks'] ); + $raw_names = array_values( array_filter( wp_list_pluck( parse_blocks( $content['raw'] ), 'blockName' ) ) ); + $this->assertSame( array( 'core/paragraph', 'core/group', 'tests/hooked-block' ), $raw_names ); + $this->assertSame( $raw_names, wp_list_pluck( $content['blocks'], 'name' ) ); + } + + /** + * @ticket 53603 + */ + public function test_get_items_returns_blocks_only_for_posts_the_user_can_edit(): void { + $contributor_id = self::$contributor_id; + $editor_id = self::$editor_id; + $this->assertIsInt( $contributor_id ); + $this->assertIsInt( $editor_id ); + + $own_post_id = self::factory()->post->create( + array( + 'post_author' => $contributor_id, + 'post_status' => 'draft', + 'post_content' => '

Own

', + ) + ); + $other_post_id = self::factory()->post->create( array( 'post_author' => $editor_id ) ); + wp_set_current_user( $contributor_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $request->set_query_params( + array( + 'context' => 'edit', + '_fields' => 'id,content.blocks', + 'status' => 'draft,publish', + ) + ); + $items = rest_get_server()->response_to_data( rest_get_server()->dispatch( $request ), false ); + $this->assertIsArray( $items ); + + $blocks_by_id = array(); + foreach ( $items as $item ) { + $this->assertIsArray( $item ); + $this->assertIsInt( $item['id'] ); + $this->assertIsArray( $item['content'] ); + $blocks_by_id[ $item['id'] ] = $item['content']['blocks'] ?? null; + } + + $this->assertArrayNotHasKey( $other_post_id, $blocks_by_id, 'Posts the user cannot edit should be left out.' ); + $this->assertArrayHasKey( $own_post_id, $blocks_by_id ); + $this->assertIsArray( $blocks_by_id[ $own_post_id ] ); + $this->assertCount( 1, $blocks_by_id[ $own_post_id ] ); + } + public function test_get_item_read_permission_custom_post_status_not_authenticated() { register_post_status( 'testpubstatus', array( 'public' => true ) ); register_post_status( 'testprivtatus', array( 'public' => false ) ); diff --git a/tests/phpunit/tests/rest-api/rest-revisions-controller.php b/tests/phpunit/tests/rest-api/rest-revisions-controller.php index fcf6b69393865..d208e382a83c1 100644 --- a/tests/phpunit/tests/rest-api/rest-revisions-controller.php +++ b/tests/phpunit/tests/rest-api/rest-revisions-controller.php @@ -1767,4 +1767,53 @@ private function restore_revision_like_the_block_editor( $parent_route, $revisio return rest_get_server()->dispatch( $request ); } + + /** + * @ticket 53603 + */ + public function test_get_item_returns_blocks_when_requested_in_edit_context(): void { + wp_set_current_user( self::$editor_id ); + wp_update_post( + array( + 'ID' => self::$post_id, + 'post_content' => '

Revised

', + ) + ); + $revisions = wp_get_post_revisions( self::$post_id ); + $revision = reset( $revisions ); + $this->assertInstanceOf( WP_Post::class, $revision ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $revision->ID ); + $request->set_query_params( + array( + 'context' => 'edit', + '_fields' => 'content.blocks', + ) + ); + $data = rest_get_server()->response_to_data( rest_get_server()->dispatch( $request ), false ); + $this->assertIsArray( $data ); + $this->assertIsArray( $data['content'] ); + $blocks = $data['content']['blocks']; + + $this->assertIsArray( $blocks ); + $this->assertCount( 1, $blocks ); + $this->assertIsArray( $blocks[0] ); + $this->assertSame( 'core/paragraph', $blocks[0]['name'] ); + $this->assertSame( '

Revised

', $blocks[0]['inner_html'] ); + } + + /** + * @ticket 53603 + */ + public function test_get_item_does_not_return_blocks_unless_requested(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 ); + $request->set_query_params( array( 'context' => 'edit' ) ); + $data = rest_get_server()->response_to_data( rest_get_server()->dispatch( $request ), false ); + $this->assertIsArray( $data ); + $this->assertIsArray( $data['content'] ); + + $this->assertArrayNotHasKey( 'blocks', $data['content'] ); + } }