diff --git a/docs/designers-developers/developers/block-api/block-context.md b/docs/designers-developers/developers/block-api/block-context.md index 0b95807ffc24e..4ee517d148e1a 100644 --- a/docs/designers-developers/developers/block-api/block-context.md +++ b/docs/designers-developers/developers/block-api/block-context.md @@ -63,4 +63,14 @@ registerBlockType( 'my-plugin/record-title', { ### PHP -_The PHP implementation of block context is currently experimental and subject to breaking changes. It will be documented in the future once the API has stabilized._ +A block's context values are available from the `context` property of the `$block` argument passed as the third argument to the `render_callback` function. + +`record-title/index.php` + +```php +register_block_type( 'my-plugin/record-title', array( + 'render_callback' => function( $attributes, $content, $block ) { + return 'The current record ID is: ' . $block->context['my-plugin/recordId']; + }, +) ); +``` diff --git a/lib/class-wp-block-list.php b/lib/class-wp-block-list.php new file mode 100644 index 0000000000000..281fa6ed4deb6 --- /dev/null +++ b/lib/class-wp-block-list.php @@ -0,0 +1,189 @@ +blocks = $blocks; + $this->available_context = $available_context; + $this->registry = $registry; + } + + /* + * ArrayAccess interface methods. + */ + + /** + * Returns true if a block exists by the specified block index, or false + * otherwise. + * + * @link https://www.php.net/manual/en/arrayaccess.offsetexists.php + * + * @param string $index Index of block to check. + * + * @return bool Whether block exists. + */ + public function offsetExists( $index ) { + return isset( $this->blocks[ $index ] ); + } + + /** + * Returns the value by the specified block index. + * + * @link https://www.php.net/manual/en/arrayaccess.offsetget.php + * + * @param string $index Index of block value to retrieve. + * + * @return mixed|null Block value if exists, or null. + */ + public function offsetGet( $index ) { + $block = $this->blocks[ $index ]; + + if ( isset( $block ) && is_array( $block ) ) { + $block = new WP_Block( $block, $this->available_context, $this->registry ); + $this->blocks[ $index ] = $block; + } + + return $block; + } + + /** + * Assign a block value by the specified block index. + * + * @link https://www.php.net/manual/en/arrayaccess.offsetset.php + * + * @param string $index Index of block value to set. + * @param mixed $value Block value. + */ + public function offsetSet( $index, $value ) { + if ( is_null( $index ) ) { + $this->blocks[] = $value; + } else { + $this->blocks[ $index ] = $value; + } + } + + /** + * Unset a block. + * + * @link https://www.php.net/manual/en/arrayaccess.offsetunset.php + * + * @param string $index Index of block value to unset. + */ + public function offsetUnset( $index ) { + unset( $this->blocks[ $index ] ); + } + + /* + * Iterator interface methods. + */ + + /** + * Rewinds back to the first element of the Iterator. + * + * @link https://www.php.net/manual/en/iterator.rewind.php + */ + public function rewind() { + reset( $this->blocks ); + } + + /** + * Returns the current element of the block list. + * + * @link https://www.php.net/manual/en/iterator.current.php + * + * @return mixed Current element. + */ + public function current() { + return $this->offsetGet( $this->key() ); + } + + /** + * Returns the key of the current element of the block list. + * + * @link https://www.php.net/manual/en/iterator.key.php + * + * @return mixed Key of the current element. + */ + public function key() { + return key( $this->blocks ); + } + + /** + * Moves the current position of the block list to the next element. + * + * @link https://www.php.net/manual/en/iterator.next.php + */ + public function next() { + next( $this->blocks ); + } + + /** + * Checks if current position is valid. + * + * @link https://www.php.net/manual/en/iterator.valid.php + */ + public function valid() { + return null !== key( $this->blocks ); + } + + /* + * Countable interface methods. + */ + + /** + * Returns the count of blocks in the list. + * + * @link https://www.php.net/manual/en/countable.count.php + * + * @return int Block count. + */ + public function count() { + return count( $this->blocks ); + } + +} diff --git a/lib/class-wp-block.php b/lib/class-wp-block.php index 55bcb9ecc5d4f..d362dea65a071 100644 --- a/lib/class-wp-block.php +++ b/lib/class-wp-block.php @@ -10,6 +10,13 @@ */ class WP_Block { + /** + * Original parsed array representation of block. + * + * @var array + */ + public $parsed_block; + /** * Name of block. * @@ -41,13 +48,6 @@ class WP_Block { */ protected $available_context; - /** - * Block attribute values. - * - * @var array - */ - public $attributes = array(); - /** * List of inner blocks (of this same class) * @@ -94,7 +94,8 @@ class WP_Block { * @param WP_Block_Type_Registry $registry Optional block type registry. */ public function __construct( $block, $available_context = array(), $registry = null ) { - $this->name = $block['blockName']; + $this->parsed_block = $block; + $this->name = $block['blockName']; if ( is_null( $registry ) ) { $registry = WP_Block_Type_Registry::get_instance(); @@ -102,14 +103,6 @@ public function __construct( $block, $available_context = array(), $registry = n $this->block_type = $registry->get_registered( $this->name ); - if ( ! empty( $block['attrs'] ) ) { - $this->attributes = $block['attrs']; - } - - if ( ! is_null( $this->block_type ) ) { - $this->attributes = $this->block_type->prepare_attributes_for_render( $this->attributes ); - } - $this->available_context = $available_context; if ( ! empty( $this->block_type->context ) ) { @@ -133,12 +126,7 @@ public function __construct( $block, $available_context = array(), $registry = n } /* phpcs:enable */ - $this->inner_blocks = array_map( - function( $inner_block ) use ( $child_context, $registry ) { - return new WP_Block( $inner_block, $child_context, $registry ); - }, - $block['innerBlocks'] - ); + $this->inner_blocks = new WP_Block_List( $block['innerBlocks'], $child_context, $registry ); } if ( ! empty( $block['innerHTML'] ) ) { @@ -150,13 +138,41 @@ function( $inner_block ) use ( $child_context, $registry ) { } } + /** + * Returns a value from an inaccessible property. + * + * This is used to lazily initialize the `attributes` property of a block, + * such that it is only prepared with default attributes at the time that + * the property is accessed. For all other inaccessible properties, a `null` + * value is returned. + * + * @param string $name Property name. + * + * @return array|null Prepared attributes, or null. + */ + public function __get( $name ) { + if ( 'attributes' === $name ) { + $this->attributes = isset( $this->parsed_block['attrs'] ) ? + $this->parsed_block['attrs'] : + array(); + + if ( ! is_null( $this->block_type ) ) { + $this->attributes = $this->block_type->prepare_attributes_for_render( $this->attributes ); + } + + return $this->attributes; + } + + return null; + } + /** * Generates the render output for the block. * * @return string Rendered block output. */ public function render() { - global $post, $_experimental_block; + global $post; $is_dynamic = $this->name && null !== $this->block_type && $this->block_type->is_dynamic(); $block_content = ''; @@ -169,15 +185,13 @@ public function render() { } if ( $is_dynamic ) { - $global_post = $post; - $global_block = $_experimental_block; - $_experimental_block = $this; - $block_content = (string) call_user_func( $this->block_type->render_callback, $this->attributes, $block_content ); - $_experimental_block = $global_block; - $post = $global_post; + $global_post = $post; + $block_content = (string) call_user_func( $this->block_type->render_callback, $this->attributes, $block_content, $this ); + $post = $global_post; } - return $block_content; + /** This filter is documented in src/wp-includes/blocks.php */ + return apply_filters( 'render_block', $block_content, $this->parsed_block ); } } diff --git a/lib/compat.php b/lib/compat.php index e38f339030cb5..48f3c7db728cf 100644 --- a/lib/compat.php +++ b/lib/compat.php @@ -175,3 +175,48 @@ function gutenberg_get_post_from_context() { } return get_post(); } + +/** + * Shim that hooks into `pre_render_block` so as to override `render_block` with + * a function that assigns block context. + * + * This can be removed when plugin support requires WordPress 5.5.0+. + * + * @see https://core.trac.wordpress.org/ticket/49927 + * + * @param string|null $pre_render The pre-rendered content. Defaults to null. + * @param array $parsed_block The parsed block being rendered. + * + * @return string String of rendered HTML. + */ +function gutenberg_render_block_with_assigned_block_context( $pre_render, $parsed_block ) { + global $post; + + /* + * If a non-null value is provided, a filter has run at an earlier priority + * and has already handled custom rendering and should take precedence. + */ + if ( null !== $pre_render ) { + return $pre_render; + } + + $source_block = $parsed_block; + + /** This filter is documented in src/wp-includes/blocks.php */ + $parsed_block = apply_filters( 'render_block_data', $parsed_block, $source_block ); + $context = array( + 'postId' => $post->ID, + + /* + * The `postType` context is largely unnecessary server-side, since the + * ID is usually sufficient on its own. That being said, since a block's + * manifest is expected to be shared between the server and the client, + * it should be included to consistently fulfill the expectation. + */ + 'postType' => $post->post_type, + ); + $block = new WP_Block( $parsed_block, $context ); + + return $block->render(); +} +add_filter( 'pre_render_block', 'gutenberg_render_block_with_assigned_block_context', 9, 2 ); diff --git a/lib/load.php b/lib/load.php index 1dca8b64972c5..ce77404390287 100644 --- a/lib/load.php +++ b/lib/load.php @@ -67,6 +67,10 @@ function gutenberg_is_experiment_enabled( $name ) { require dirname( __FILE__ ) . '/class-wp-block.php'; } +if ( ! class_exists( 'WP_Block_List' ) ) { + require dirname( __FILE__ ) . '/class-wp-block-list.php'; +} + require dirname( __FILE__ ) . '/compat.php'; require dirname( __FILE__ ) . '/blocks.php'; diff --git a/packages/block-library/src/post-title/index.php b/packages/block-library/src/post-title/index.php index d6218034553f3..0e30b478a39fe 100644 --- a/packages/block-library/src/post-title/index.php +++ b/packages/block-library/src/post-title/index.php @@ -8,15 +8,18 @@ /** * Renders the `core/post-title` block on the server. * + * @param array $attributes Block attributes. + * @param string $content Block default content. + * @param WP_Block $block Block instance. + * * @return string Returns the filtered post title for the current post wrapped inside "h1" tags. */ -function render_block_core_post_title() { - $post = gutenberg_get_post_from_context(); - if ( ! $post ) { +function render_block_core_post_title( $attributes, $content, $block ) { + if ( ! isset( $block->context['postId'] ) ) { return ''; } - return '

' . get_the_title( $post ) . '

'; + return '

' . get_the_title( $block->context['postId'] ) . '

'; } /** diff --git a/packages/e2e-tests/plugins/block-context.php b/packages/e2e-tests/plugins/block-context.php index 380660f6f08ef..8f3ca467d5004 100644 --- a/packages/e2e-tests/plugins/block-context.php +++ b/packages/e2e-tests/plugins/block-context.php @@ -47,7 +47,16 @@ function gutenberg_test_register_context_blocks() { register_block_type( 'gutenberg/test-context-consumer', array( - 'context' => array( 'gutenberg/recordId' ), + 'context' => array( 'gutenberg/recordId' ), + 'render_callback' => function( $attributes, $content, $block ) { + $record_id = $block->context['gutenberg/recordId']; + + if ( ! is_int( $record_id ) ) { + throw new Exception( 'Expected numeric recordId' ); + } + + return 'The record ID is: ' . filter_var( $record_id, FILTER_VALIDATE_INT ); + }, ) ); } diff --git a/phpunit/class-block-context-test.php b/phpunit/class-block-context-test.php index 769b0e0242916..a990d882e5fb6 100644 --- a/phpunit/class-block-context-test.php +++ b/phpunit/class-block-context-test.php @@ -67,8 +67,6 @@ protected function register_block_type( $name, $args ) { * its inner blocks. */ function test_provides_block_context() { - $this->markTestSkipped(); - $provided_context = array(); $this->register_block_type( @@ -106,10 +104,8 @@ function test_provides_block_context() { 'gutenberg/contextWithAssigned', 'gutenberg/contextWithoutDefault', ), - 'render_callback' => function() use ( &$provided_context ) { - global $_experimental_block; - - $provided_context[] = $_experimental_block->context; + 'render_callback' => function( $attributes, $content, $block ) use ( &$provided_context ) { + $provided_context[] = $block->context; return ''; }, @@ -133,4 +129,38 @@ function test_provides_block_context() { ); } + /** + * Tests that a block can receive default-provided context through + * render_block. + */ + function test_provides_default_context() { + global $post; + + $provided_context = array(); + + $this->register_block_type( + 'gutenberg/test-context-consumer', + array( + 'context' => array( 'postId', 'postType' ), + 'render_callback' => function( $attributes, $content, $block ) use ( &$provided_context ) { + $provided_context[] = $block->context; + + return ''; + }, + ) + ); + + $parsed_blocks = parse_blocks( '' ); + + render_block( $parsed_blocks[0] ); + + $this->assertEquals( + array( + 'postId' => $post->ID, + 'postType' => $post->post_type, + ), + $provided_context[0] + ); + } + } diff --git a/phpunit/class-wp-block-list-test.php b/phpunit/class-wp-block-list-test.php new file mode 100644 index 0000000000000..ed6565f7a9f94 --- /dev/null +++ b/phpunit/class-wp-block-list-test.php @@ -0,0 +1,94 @@ +registry = new WP_Block_Type_Registry(); + $this->registry->register( 'core/example', array() ); + } + + /** + * Tear down each test method. + */ + public function tearDown() { + parent::tearDown(); + + $this->registry = null; + } + + function test_array_access() { + $parsed_blocks = parse_blocks( '' ); + $context = array(); + $blocks = new WP_Block_List( $parsed_blocks, $context, $this->registry ); + + // Test "offsetExists". + $this->assertTrue( isset( $blocks[0] ) ); + + // Test "offsetGet". + $this->assertEquals( 'core/example', $blocks[0]->name ); + + // Test "offsetSet". + $parsed_blocks[0]['blockName'] = 'core/updated'; + $blocks[0] = new WP_Block( $parsed_blocks[0], $context, $this->registry ); + $this->assertEquals( 'core/updated', $blocks[0]->name ); + + // Test "offsetUnset". + unset( $blocks[0] ); + $this->assertFalse( isset( $blocks[0] ) ); + } + + function test_iterable() { + $parsed_blocks = parse_blocks( '' ); + $context = array(); + $blocks = new WP_Block_List( $parsed_blocks, $context, $this->registry ); + $assertions = 0; + + foreach ( $blocks as $block ) { + $this->assertEquals( 'core/example', $block->name ); + $assertions++; + foreach ( $block->inner_blocks as $inner_block ) { + $this->assertEquals( 'core/example', $inner_block->name ); + $assertions++; + } + } + + $blocks->rewind(); + while ( $blocks->valid() ) { + $key = $blocks->key(); + $block = $blocks->current(); + $this->assertEquals( 0, $key ); + $assertions++; + $this->assertEquals( 'core/example', $block->name ); + $assertions++; + $blocks->next(); + } + + $this->assertEquals( 4, $assertions ); + } + + function test_countable() { + $parsed_blocks = parse_blocks( '' ); + $context = array(); + $blocks = new WP_Block_List( $parsed_blocks, $context, $this->registry ); + + $this->assertEquals( 1, count( $blocks ) ); + } + +} diff --git a/phpunit/class-wp-block-test.php b/phpunit/class-wp-block-test.php index dba70ae06c09a..236ed46c0cb0e 100644 --- a/phpunit/class-wp-block-test.php +++ b/phpunit/class-wp-block-test.php @@ -32,6 +32,10 @@ public function tearDown() { $this->registry = null; } + function filter_render_block( $content, $parsed_block ) { + return 'Original: "' . $content . '", from block "' . $parsed_block['blockName'] . '"'; + } + function test_constructor_assigns_properties_from_parsed_block() { $this->registry->register( 'core/example', array() ); @@ -40,6 +44,7 @@ function test_constructor_assigns_properties_from_parsed_block() { $context = array(); $block = new WP_Block( $parsed_block, $context, $this->registry ); + $this->assertSame( $parsed_block, $block->parsed_block ); $this->assertEquals( $parsed_block['blockName'], $block->name ); $this->assertEquals( $parsed_block['attrs'], $block->attributes ); $this->assertEquals( $parsed_block['innerContent'], $block->inner_content ); @@ -68,7 +73,7 @@ function test_constructor_assigns_block_type_from_registry() { ); } - function test_constructor_assigns_attributes_with_defaults() { + function test_lazily_assigns_attributes_with_defaults() { $this->registry->register( 'core/example', array( @@ -99,7 +104,7 @@ function test_constructor_assigns_attributes_with_defaults() { ); } - function test_constructor_assigns_attributes_with_only_defaults() { + function test_lazily_assigns_attributes_with_only_defaults() { $this->registry->register( 'core/example', array( @@ -120,6 +125,8 @@ function test_constructor_assigns_attributes_with_only_defaults() { $block = new WP_Block( $parsed_block, $context, $this->registry ); $this->assertEquals( array( 'defaulted' => 10 ), $block->attributes ); + // Intentionally call a second time, to ensure property was assigned. + $this->assertEquals( array( 'defaulted' => 10 ), $block->attributes ); } function test_constructor_assigns_context_from_block_type() { @@ -246,37 +253,40 @@ function test_render_static_block_type_returns_own_content() { $this->assertSame( 'abc', $block->render() ); } - function test_render_assigns_instance_global_for_render_callback() { + function test_render_passes_block_for_render_callback() { $this->registry->register( 'core/greeting', array( - 'attributes' => array( - 'toWhom' => array( - 'type' => 'string', - ), - 'punctuation' => array( - 'type' => 'string', - 'default' => '!', - ), - ), - 'render_callback' => function() { - global $_experimental_block; - - return sprintf( - 'Hello %s%s', - $_experimental_block->attributes['toWhom'], - $_experimental_block->attributes['punctuation'] - ); + 'render_callback' => function( $attributes, $content, $block ) { + return sprintf( 'Hello from %s', $block->name ); }, ) ); - $parsed_blocks = parse_blocks( '' ); + $parsed_blocks = parse_blocks( '' ); $parsed_block = $parsed_blocks[0]; $context = array(); $block = new WP_Block( $parsed_block, $context, $this->registry ); - $this->assertSame( 'Hello world!', $block->render() ); + $this->assertSame( 'Hello from core/greeting', $block->render() ); + } + + function test_render_applies_render_block_filter() { + $this->registry->register( 'core/example', array() ); + + add_filter( 'render_block', array( $this, 'filter_render_block' ), 10, 2 ); + + $parsed_blocks = parse_blocks( 'StaticInner' ); + $parsed_block = $parsed_blocks[0]; + $context = array(); + $block = new WP_Block( $parsed_block, $context, $this->registry ); + + $rendered_content = $block->render(); + + remove_filter( 'render_block', array( $this, 'filter_render_block' ) ); + + $this->assertSame( 'Original: "StaticOriginal: "Inner", from block "core/example"", from block "core/example"', $rendered_content ); + } function test_passes_attributes_to_render_callback() {