diff --git a/src/wp-includes/class-wp-block-type-registry.php b/src/wp-includes/class-wp-block-type-registry.php new file mode 100644 index 0000000000000..11e42c14cb2a3 --- /dev/null +++ b/src/wp-includes/class-wp-block-type-registry.php @@ -0,0 +1,183 @@ + $instance` pairs. + * + * @since 5.0.0 + * @access private + * @var WP_Block_Type[] + */ + private $registered_block_types = array(); + + /** + * Container for the main instance of the class. + * + * @since 5.0.0 + * @access private + * @static + * @var WP_Block_Type_Registry|null + */ + private static $instance = null; + + /** + * Registers a block type. + * + * @since 5.0.0 + * @access public + * + * @param string|WP_Block_Type $name Block type name including namespace, or alternatively a + * complete WP_Block_Type instance. In case a WP_Block_Type + * is provided, the $args parameter will be ignored. + * @param array $args { + * Optional. Array of block type arguments. Any arguments may be defined, however the + * ones described below are supported by default. Default empty array. + * + * @type callable $render_callback Callback used to render blocks of this block type. + * @type array $attributes Block attributes mapping, property name to schema. + * } + * @return WP_Block_Type|false The registered block type on success, or false on failure. + */ + public function register( $name, $args = array() ) { + $block_type = null; + if ( $name instanceof WP_Block_Type ) { + $block_type = $name; + $name = $block_type->name; + } + + if ( ! is_string( $name ) ) { + $message = __( 'Block type names must be strings.' ); + _doing_it_wrong( __METHOD__, $message, '5.0.0' ); + return false; + } + + if ( preg_match( '/[A-Z]+/', $name ) ) { + $message = __( 'Block type names must not contain uppercase characters.' ); + _doing_it_wrong( __METHOD__, $message, '5.0.0' ); + return false; + } + + $name_matcher = '/^[a-z0-9-]+\/[a-z0-9-]+$/'; + if ( ! preg_match( $name_matcher, $name ) ) { + $message = __( 'Block type names must contain a namespace prefix. Example: my-plugin/my-custom-block-type' ); + _doing_it_wrong( __METHOD__, $message, '5.0.0' ); + return false; + } + + if ( $this->is_registered( $name ) ) { + /* translators: 1: block name */ + $message = sprintf( __( 'Block type "%s" is already registered.' ), $name ); + _doing_it_wrong( __METHOD__, $message, '5.0.0' ); + return false; + } + + if ( ! $block_type ) { + $block_type = new WP_Block_Type( $name, $args ); + } + + $this->registered_block_types[ $name ] = $block_type; + + return $block_type; + } + + /** + * Unregisters a block type. + * + * @since 5.0.0 + * @access public + * + * @param string|WP_Block_Type $name Block type name including namespace, or alternatively a + * complete WP_Block_Type instance. + * @return WP_Block_Type|false The unregistered block type on success, or false on failure. + */ + public function unregister( $name ) { + if ( $name instanceof WP_Block_Type ) { + $name = $name->name; + } + + if ( ! $this->is_registered( $name ) ) { + /* translators: 1: block name */ + $message = sprintf( __( 'Block type "%s" is not registered.' ), $name ); + _doing_it_wrong( __METHOD__, $message, '5.0.0' ); + return false; + } + + $unregistered_block_type = $this->registered_block_types[ $name ]; + unset( $this->registered_block_types[ $name ] ); + + return $unregistered_block_type; + } + + /** + * Retrieves a registered block type. + * + * @since 5.0.0 + * @access public + * + * @param string $name Block type name including namespace. + * @return WP_Block_Type|null The registered block type, or null if it is not registered. + */ + public function get_registered( $name ) { + if ( ! $this->is_registered( $name ) ) { + return null; + } + + return $this->registered_block_types[ $name ]; + } + + /** + * Retrieves all registered block types. + * + * @since 5.0.0 + * @access public + * + * @return WP_Block_Type[] Associative array of `$block_type_name => $block_type` pairs. + */ + public function get_all_registered() { + return $this->registered_block_types; + } + + /** + * Checks if a block type is registered. + * + * @since 5.0.0 + * @access public + * + * @param string $name Block type name including namespace. + * @return bool True if the block type is registered, false otherwise. + */ + public function is_registered( $name ) { + return isset( $this->registered_block_types[ $name ] ); + } + + /** + * Utility method to retrieve the main instance of the class. + * + * The instance will be created if it does not exist yet. + * + * @since 5.0.0 + * @access public + * @static + * + * @return WP_Block_Type_Registry The main instance. + */ + public static function get_instance() { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } +} diff --git a/src/wp-includes/class-wp-block-type.php b/src/wp-includes/class-wp-block-type.php new file mode 100644 index 0000000000000..b064143dd7ec2 --- /dev/null +++ b/src/wp-includes/class-wp-block-type.php @@ -0,0 +1,199 @@ +name = $block_type; + + $this->set_props( $args ); + } + + /** + * Renders the block type output for given attributes. + * + * @since 5.0.0 + * + * @param array $attributes Optional. Block attributes. Default empty array. + * @param string $content Optional. Block content. Default empty string. + * @return string Rendered block type output. + */ + public function render( $attributes = array(), $content = '' ) { + if ( ! $this->is_dynamic() ) { + return ''; + } + + $attributes = $this->prepare_attributes_for_render( $attributes ); + + return (string) call_user_func( $this->render_callback, $attributes, $content ); + } + + /** + * Returns true if the block type is dynamic, or false otherwise. A dynamic + * block is one which defers its rendering to occur on-demand at runtime. + * + * @return boolean Whether block type is dynamic. + */ + public function is_dynamic() { + return is_callable( $this->render_callback ); + } + + /** + * Validates attributes against the current block schema, populating + * defaulted and missing values, and omitting unknown attributes. + * + * @param array $attributes Original block attributes. + * @return array Prepared block attributes. + */ + public function prepare_attributes_for_render( $attributes ) { + if ( ! isset( $this->attributes ) ) { + return $attributes; + } + + $prepared_attributes = array(); + + foreach ( $this->attributes as $attribute_name => $schema ) { + $value = null; + + if ( isset( $attributes[ $attribute_name ] ) ) { + $is_valid = rest_validate_value_from_schema( $attributes[ $attribute_name ], $schema ); + if ( ! is_wp_error( $is_valid ) ) { + $value = rest_sanitize_value_from_schema( $attributes[ $attribute_name ], $schema ); + } + } + + if ( is_null( $value ) && isset( $schema['default'] ) ) { + $value = $schema['default']; + } + + $prepared_attributes[ $attribute_name ] = $value; + } + + return $prepared_attributes; + } + + /** + * Sets block type properties. + * + * @since 5.0.0 + * + * @param array|string $args Array or string of arguments for registering a block type. + */ + public function set_props( $args ) { + $args = wp_parse_args( + $args, + array( + 'render_callback' => null, + ) + ); + + $args['name'] = $this->name; + + foreach ( $args as $property_name => $property_value ) { + $this->$property_name = $property_value; + } + } + + /** + * Get all available block attributes including possible layout attribute from Columns block. + * + * @return array Array of attributes. + */ + public function get_attributes() { + return is_array( $this->attributes ) ? + array_merge( + $this->attributes, + array( + 'layout' => array( + 'type' => 'string', + ), + ) + ) : + array( + 'layout' => array( + 'type' => 'string', + ), + ); + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index 44c8a91a07078..ba0f9388d784f 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -240,6 +240,8 @@ require( ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-post-meta-fields.php' ); require( ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-term-meta-fields.php' ); require( ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-user-meta-fields.php' ); +require( ABSPATH . WPINC . '/class-wp-block-type.php' ); +require( ABSPATH . WPINC . '/class-wp-block-type-registry.php' ); $GLOBALS['wp_embed'] = new WP_Embed(); diff --git a/tests/phpunit/tests/blocks/block-type-registry.php b/tests/phpunit/tests/blocks/block-type-registry.php new file mode 100644 index 0000000000000..42ef341a76968 --- /dev/null +++ b/tests/phpunit/tests/blocks/block-type-registry.php @@ -0,0 +1,156 @@ +registry = new WP_Block_Type_Registry(); + } + + function tearDown() { + parent::tearDown(); + + $this->registry = null; + } + + /** + * Should reject numbers + * + * @expectedIncorrectUsage WP_Block_Type_Registry::register + */ + function test_invalid_non_string_names() { + $result = $this->registry->register( 1, array() ); + $this->assertFalse( $result ); + } + + /** + * Should reject blocks without a namespace + * + * @expectedIncorrectUsage WP_Block_Type_Registry::register + */ + function test_invalid_names_without_namespace() { + $result = $this->registry->register( 'paragraph', array() ); + $this->assertFalse( $result ); + } + + /** + * Should reject blocks with invalid characters + * + * @expectedIncorrectUsage WP_Block_Type_Registry::register + */ + function test_invalid_characters() { + $result = $this->registry->register( 'still/_doing_it_wrong', array() ); + $this->assertFalse( $result ); + } + + /** + * Should reject blocks with uppercase characters + * + * @expectedIncorrectUsage WP_Block_Type_Registry::register + */ + function test_uppercase_characters() { + $result = $this->registry->register( 'Core/Paragraph', array() ); + $this->assertFalse( $result ); + } + + /** + * Should accept valid block names + */ + function test_register_block_type() { + $name = 'core/paragraph'; + $settings = array( + 'icon' => 'editor-paragraph', + ); + + $block_type = $this->registry->register( $name, $settings ); + $this->assertEquals( $name, $block_type->name ); + $this->assertEquals( $settings['icon'], $block_type->icon ); + $this->assertEquals( $block_type, $this->registry->get_registered( $name ) ); + } + + /** + * Should fail to re-register the same block + * + * @expectedIncorrectUsage WP_Block_Type_Registry::register + */ + function test_register_block_type_twice() { + $name = 'core/paragraph'; + $settings = array( + 'icon' => 'editor-paragraph', + ); + + $result = $this->registry->register( $name, $settings ); + $this->assertNotFalse( $result ); + $result = $this->registry->register( $name, $settings ); + $this->assertFalse( $result ); + } + + /** + * Should accept a WP_Block_Type instance + */ + function test_register_block_type_instance() { + $block_type = new WP_Dummy_Block_Type( 'core/dummy' ); + + $result = $this->registry->register( $block_type ); + $this->assertSame( $block_type, $result ); + } + + /** + * Unregistering should fail if a block is not registered + * + * @expectedIncorrectUsage WP_Block_Type_Registry::unregister + */ + function test_unregister_not_registered_block() { + $result = $this->registry->unregister( 'core/unregistered' ); + $this->assertFalse( $result ); + } + + /** + * Should unregister existing blocks + */ + function test_unregister_block_type() { + $name = 'core/paragraph'; + $settings = array( + 'icon' => 'editor-paragraph', + ); + + $this->registry->register( $name, $settings ); + $block_type = $this->registry->unregister( $name ); + $this->assertEquals( $name, $block_type->name ); + $this->assertEquals( $settings['icon'], $block_type->icon ); + $this->assertFalse( $this->registry->is_registered( $name ) ); + } + + function test_get_all_registered() { + $names = array( 'core/paragraph', 'core/image', 'core/blockquote' ); + $settings = array( + 'icon' => 'random', + ); + + foreach ( $names as $name ) { + $this->registry->register( $name, $settings ); + } + + $registered = $this->registry->get_all_registered(); + $this->assertEqualSets( $names, array_keys( $registered ) ); + } +} diff --git a/tests/phpunit/tests/blocks/block-type.php b/tests/phpunit/tests/blocks/block-type.php new file mode 100644 index 0000000000000..44aee07031f6a --- /dev/null +++ b/tests/phpunit/tests/blocks/block-type.php @@ -0,0 +1,261 @@ +user->create( + array( + 'role' => 'editor', + ) + ); + + self::$post_with_blocks = self::factory()->post->create( + array( + 'post_title' => 'Example', + 'post_content' => "\n
Tester
\n", + ) + ); + + self::$post_without_blocks = self::factory()->post->create( + array( + 'post_title' => 'Example', + 'post_content' => 'Tester', + ) + ); + } + + function test_set_props() { + $name = 'core/dummy'; + $args = array( + 'render_callback' => array( $this, 'render_dummy_block' ), + 'foo' => 'bar', + ); + + $block_type = new WP_Block_Type( $name, $args ); + + $this->assertSame( $name, $block_type->name ); + $this->assertSame( $args['render_callback'], $block_type->render_callback ); + $this->assertSame( $args['foo'], $block_type->foo ); + } + + function test_render() { + $attributes = array( + 'foo' => 'bar', + 'bar' => 'foo', + ); + + $block_type = new WP_Block_Type( + 'core/dummy', + array( + 'render_callback' => array( $this, 'render_dummy_block' ), + ) + ); + $output = $block_type->render( $attributes ); + $this->assertEquals( $attributes, json_decode( $output, true ) ); + } + + function test_render_with_content() { + $attributes = array( + 'foo' => 'bar', + 'bar' => 'foo', + ); + + $content = 'baz'; + + $expected = array_merge( $attributes, array( '_content' => $content ) ); + + $block_type = new WP_Block_Type( + 'core/dummy', + array( + 'render_callback' => array( $this, 'render_dummy_block_with_content' ), + ) + ); + $output = $block_type->render( $attributes, $content ); + $this->assertEquals( $expected, json_decode( $output, true ) ); + } + + function test_render_for_static_block() { + $block_type = new WP_Block_Type( 'core/dummy', array() ); + $output = $block_type->render(); + + $this->assertEquals( '', $output ); + } + + function test_is_dynamic_for_static_block() { + $block_type = new WP_Block_Type( 'core/dummy', array() ); + + $this->assertFalse( $block_type->is_dynamic() ); + } + + function test_is_dynamic_for_dynamic_block() { + $block_type = new WP_Block_Type( + 'core/dummy', + array( + 'render_callback' => array( $this, 'render_dummy_block' ), + ) + ); + + $this->assertTrue( $block_type->is_dynamic() ); + } + + function test_prepare_attributes() { + $attributes = array( + 'correct' => 'include', + 'wrongType' => 5, + 'wrongTypeDefaulted' => 5, + /* missingDefaulted */ + 'undefined' => 'omit', + ); + + $block_type = new WP_Block_Type( + 'core/dummy', + array( + 'attributes' => array( + 'correct' => array( + 'type' => 'string', + ), + 'wrongType' => array( + 'type' => 'string', + ), + 'wrongTypeDefaulted' => array( + 'type' => 'string', + 'default' => 'defaulted', + ), + 'missingDefaulted' => array( + 'type' => 'string', + 'default' => 'define', + ), + ), + ) + ); + + $prepared_attributes = $block_type->prepare_attributes_for_render( $attributes ); + + $this->assertEquals( + array( + 'correct' => 'include', + 'wrongType' => null, + 'wrongTypeDefaulted' => 'defaulted', + 'missingDefaulted' => 'define', + ), + $prepared_attributes + ); + } + + function test_has_block_with_mixed_content() { + $mixed_post_content = 'before' . + '' . + '' . + ' +testing the test
+ ' . + 'between' . + '' . + '' . + 'after'; + + $this->assertTrue( has_block( 'core/dummy', $mixed_post_content ) ); + + $this->assertTrue( has_block( 'core/dummy_atts', $mixed_post_content ) ); + + $this->assertTrue( has_block( 'core/dummy-child', $mixed_post_content ) ); + + $this->assertTrue( has_block( 'core/self-close-dummy', $mixed_post_content ) ); + + $this->assertTrue( has_block( 'custom/dummy', $mixed_post_content ) ); + + // checking for a partial block name should fail. + $this->assertFalse( has_block( 'core/dumm', $mixed_post_content ) ); + + // checking for a wrong namespace should fail. + $this->assertFalse( has_block( 'custom/dummy_atts', $mixed_post_content ) ); + + // checking for namespace only should not work. Or maybe ... ? + $this->assertFalse( has_block( 'core', $mixed_post_content ) ); + } + + function test_has_block_with_invalid_content() { + // some content with invalid HMTL comments and a single valid block. + $invalid_content = 'before' . + '' . + '' . + '' . + '' . + 'after'; + + $this->assertFalse( has_block( 'core/text', self::$post_without_blocks ) ); + + $this->assertFalse( has_block( 'core/weird-space', $invalid_content ) ); + + $this->assertFalse( has_block( 'core/untrimmed-left', $invalid_content ) ); + + $this->assertFalse( has_block( 'core/untrimmed-right', $invalid_content ) ); + + $this->assertTrue( has_block( 'core/dummy', $invalid_content ) ); + } + + function test_post_has_block() { + // should fail for a non-existent block `custom/dummy`. + $this->assertFalse( has_block( 'custom/dummy', self::$post_with_blocks ) ); + + // this functions should not work without the second param until the $post global is set. + $this->assertFalse( has_block( 'core/text' ) ); + $this->assertFalse( has_block( 'core/dummy' ) ); + + global $post; + $post = get_post( self::$post_with_blocks ); + + // check if the function correctly detects content from the $post global. + $this->assertTrue( has_block( 'core/text' ) ); + // even if it detects a proper $post global it should still be false for a missing block. + $this->assertFalse( has_block( 'core/dummy' ) ); + } + + function render_dummy_block( $attributes ) { + return json_encode( $attributes ); + } + + function render_dummy_block_with_content( $attributes, $content ) { + $attributes['_content'] = $content; + + return json_encode( $attributes ); + } +}