diff --git a/docs/rfc/block-registration.md b/docs/rfc/block-registration.md index ae8df5dd5fc5b..aed1e31c5409c 100644 --- a/docs/rfc/block-registration.md +++ b/docs/rfc/block-registration.md @@ -6,7 +6,7 @@ This RFC is intended to serve both as a specification and as documentation for t Behind any block type registration is some abstract concept of a unit of content. This content type can be described without consideration of any particular technology. In much the same way, we should be able to describe the core constructs of a block type in a way which can be interpreted in any runtime. -In more practical terms, an implementation should fulfill requirements that... +In more practical terms, an implementation should fulfill requirements that: * A block type registration should be declarative and context-agnostic. Any runtime (PHP, JS, or other) should be able to interpret the basics of a block type (see "Block API" in the sections below) and should be able to fetch or retrieve the definitions of the context-specific implementation details. The following things should be made possible: * Fetching the available block types through REST APIs. @@ -16,7 +16,7 @@ In more practical terms, an implementation should fulfill requirements that... It can statically analyze the files of any plugin to retrieve blocks and their properties. * It should not require a build tool compilation step (e.g. Babel, Webpack) to author code which would be referenced in a block type definition. -* There should allow the potential to dynamically load ("lazy-load") block types, or parts of block type definitions. It practical terms, it means that the editor should be able to be loaded without enqueuing all the assets (scripts and styles) of all block types. What it needs is the basic metadata (`title`, `description`, `category`, `icon`, etc...) to start with. It should be fine to defer loading all other code (`edit`, `save`, `transforms`, and other JavaScript implementations) until it is explicitly used (inserted into the post content). +* There should allow the potential to dynamically load ("lazy-load") block types, or parts of block type definitions. It practical terms, it means that the editor should be able to be loaded without enqueuing all the assets (scripts and styles) of all block types. What it needs is the basic metadata (`title`, `description`, `category`, `icon`, etc…) to start with. It should be fine to defer loading all other code (`edit`, `save`, `transforms`, and other JavaScript implementations) until it is explicitly used (inserted into the post content). ## References @@ -67,7 +67,7 @@ To register a new block type, start by creating a `block.json` file. This file: "category": "text", "parent": [ "core/group" ], "icon": "star", - "description": "Shows warning, error or success notices ...", + "description": "Shows warning, error or success notices…", "keywords": [ "alert", "message" ], "textdomain": "my-plugin", "attributes": { @@ -90,10 +90,10 @@ To register a new block type, start by creating a `block.json` file. This file: "message": "This is a notice!" }, }, - "editorScript": "build/editor.js", - "script": "build/main.js", - "editorStyle": "build/editor.css", - "style": "build/style.css" + "editorScript": "file:./build/index.js", + "script": "file:./build/script.js", + "editorStyle": "file:./build/index.css", + "style": "file:./build/style.css" } ``` @@ -295,7 +295,7 @@ Plugins and Themes can also register [custom block style](/docs/designers-develo "example": { "attributes": { "message": "This is a notice!" - }, + } } } ``` @@ -312,7 +312,7 @@ Plugins and Themes can also register [custom block style](/docs/designers-develo * Property: `editorScript` ```json -{ "editorScript": "build/editor.js" } +{ "editorScript": "file:./build/index.js" } ``` Block type editor script definition. It will only be enqueued in the context of the editor. @@ -325,7 +325,7 @@ Block type editor script definition. It will only be enqueued in the context of * Property: `script` ```json -{ "script": "build/main.js" } +{ "script": "file:./build/script.js" } ``` Block type frontend script definition. It will be enqueued both in the editor and when viewing the content on the front of the site. @@ -338,7 +338,7 @@ Block type frontend script definition. It will be enqueued both in the editor an * Property: `editorStyle` ```json -{ "editorStyle": "build/editor.css" } +{ "editorStyle": "file:./build/index.css" } ``` Block type editor style definition. It will only be enqueued in the context of the editor. @@ -351,7 +351,7 @@ Block type editor style definition. It will only be enqueued in the context of t * Property: `style` ```json -{ "style": "build/style.css" } +{ "style": "file:./build/style.css" } ``` Block type frontend style definition. It will be enqueued both in the editor and when viewing the content on the front of the site. @@ -387,18 +387,23 @@ In the case of [dynamic blocks](/docs/designers-developers/developers/tutorials/ ### `WPDefinedAsset` -The `WPDefinedAsset` type is a subtype of string, where the value must represent an absolute or relative path to a JavaScript or CSS file. +The `WPDefinedAsset` type is a subtype of string, where the value represents a path to a JavaScript or CSS file relative to where `block.json` file is located. The path provided must be prefixed with `file:`. This approach is based on how npm handles [local paths](https://docs.npmjs.com/files/package.json#local-paths) for packages. + +An alternative would be a script or style handle name referencing a registered asset using WordPress helpers. **Example:** In `block.json`: ```json -{ "editorScript": "build/editor.js" } +{ + "editorScript": "file:./build/index.js", + "editorStyle": "my-editor-style-handle" +} ``` #### WordPress context -In the context of WordPress, when a block is registered with PHP, it will automatically register all scripts and styles that are found in the `block.json` file. +In the context of WordPress, when a block is registered with PHP, it will automatically register all scripts and styles that are found in the `block.json` file and use file paths rather than asset handles. That's why, the `WPDefinedAsset` type has to offer a way to mirror also the shape of params necessary to register scripts and styles using [`wp_register_script`](https://developer.wordpress.org/reference/functions/wp_register_script/) and [`wp_register_style`](https://developer.wordpress.org/reference/functions/wp_register_style/), and then assign these as handles associated with your block using the `script`, `style`, `editor_script`, and `editor_style` block type registration settings. @@ -419,7 +424,7 @@ build/ In `block.json`: ```json -{ "editorScript": "build/index.js" } +{ "editorScript": "file:./build/index.js" } ``` In `build/index.asset.php`: @@ -480,7 +485,7 @@ Implementation should follow the existing [get_plugin_data](https://codex.wordpr There is also a new API method proposed `register_block_type_from_metadata` that aims to simplify the block type registration on the server from metadata stored in the `block.json` file. This function is going to handle also all necessary work to make internationalization work seamlessly for metadata defined. This function takes two params: -- `$path` (`string`) – path to the folder where the `block.json` file is located. +- `$path` (`string`) – path to the folder where the `block.json` file is located or full path to the metadata file if named differently. - `$args` (`array`) – an optional array of block type arguments. Default value: `[]`. Any arguments may be defined. However, the one described below is supported by default: - `$render_callback` (`callable`) – callback used to render blocks of this block type. diff --git a/lib/compat.php b/lib/compat.php index 9dd224adb2f19..c5966012f4c09 100644 --- a/lib/compat.php +++ b/lib/compat.php @@ -9,6 +9,132 @@ */ if ( ! function_exists( 'register_block_type_from_metadata' ) ) { + /** + * Removes the block asset's path prefix if provided. + * + * @since 5.5.0 + * + * @param string $asset_handle_or_path Asset handle or prefixed path. + * + * @return string Path without the prefix or the original value. + */ + function remove_block_asset_path_prefix( $asset_handle_or_path ) { + $path_prefix = 'file:'; + if ( strpos( $asset_handle_or_path, $path_prefix ) !== 0 ) { + return $asset_handle_or_path; + } + return substr( + $asset_handle_or_path, + strlen( $path_prefix ) + ); + } + + /** + * Generates the name for an asset based on the name of the block + * and the field name provided. + * + * @since 5.5.0 + * + * @param string $block_name Name of the block. + * @param string $field_name Name of the metadata field. + * + * @return string Generated asset name for the block's field. + */ + function generate_block_asset_handle( $block_name, $field_name ) { + $field_mappings = array( + 'editorScript' => 'editor-script', + 'script' => 'script', + 'editorStyle' => 'editor-style', + 'style' => 'style', + ); + return str_replace( '/', '-', $block_name ) . + '-' . $field_mappings[ $field_name ]; + } + + /** + * Finds a script handle for the selected block metadata field. It detects + * when a path to file was provided and finds a corresponding + * asset file with details necessary to register the script under + * automatically generated handle name. It returns unprocessed script handle + * otherwise. + * + * @since 5.5.0 + * + * @param array $metadata Block metadata. + * @param string $field_name Field name to pick from metadata. + * + * @return string|boolean Script handle provided directly or created through + * script's registration, or false on failure. + */ + function register_block_script_handle( $metadata, $field_name ) { + if ( empty( $metadata[ $field_name ] ) ) { + return false; + } + $script_handle = $metadata[ $field_name ]; + $script_path = remove_block_asset_path_prefix( $metadata[ $field_name ] ); + if ( $script_handle === $script_path ) { + return $script_handle; + } + + $script_handle = generate_block_asset_handle( $metadata['name'], $field_name ); + $script_asset_path = realpath( + dirname( $metadata['file'] ) . '/' . + substr_replace( $script_path, '.asset.php', - strlen( '.js' ) ) + ); + if ( ! file_exists( $script_asset_path ) ) { + $message = sprintf( + /* translators: %1: field name. %2: block name */ + __( 'The asset file for the "%1$s" defined in "%2$s" block definition is missing.', 'default' ), + $field_name, + $metadata['name'] + ); + _doing_it_wrong( __FUNCTION__, $message, '5.5.0' ); + return false; + } + $script_asset = require( $script_asset_path ); + $result = wp_register_script( + $script_handle, + plugins_url( $script_path, $metadata['file'] ), + $script_asset['dependencies'], + $script_asset['version'] + ); + return $result ? $script_handle : false; + } + + /** + * Finds a style handle for the block metadata field. It detects when a path + * to file was provided and registers the style under automatically + * generated handle name. It returns unprocessed style handle otherwise. + * + * @since 5.5.0 + * + * @param array $metadata Block metadata. + * @param string $field_name Field name to pick from metadata. + * + * @return string|boolean Style handle provided directly or created through + * style's registration, or false on failure. + */ + function register_block_style_handle( $metadata, $field_name ) { + if ( empty( $metadata[ $field_name ] ) ) { + return false; + } + $style_handle = $metadata[ $field_name ]; + $style_path = remove_block_asset_path_prefix( $metadata[ $field_name ] ); + if ( $style_handle === $style_path ) { + return $style_handle; + } + + $style_handle = generate_block_asset_handle( $metadata['name'], $field_name ); + $block_dir = dirname( $metadata['file'] ); + $result = wp_register_style( + $style_handle, + plugins_url( $style_path, $metadata['file'] ), + array(), + filemtime( realpath( "$block_dir/$style_path" ) ) + ); + return $result ? $style_handle : false; + } + /** * Registers a block type from metadata stored in the `block.json` file. * @@ -25,22 +151,72 @@ * @return WP_Block_Type|false The registered block type on success, or false on failure. */ function register_block_type_from_metadata( $file_or_folder, $args = array() ) { - $file = ( substr( $file_or_folder, -10 ) !== 'block.json' ) ? - trailingslashit( $file_or_folder ) . 'block.json' : + $filename = 'block.json'; + $metadata_file = ( substr( $file_or_folder, -strlen( $filename ) ) !== $filename ) ? + trailingslashit( $file_or_folder ) . $filename : $file_or_folder; - if ( ! file_exists( $file ) ) { + if ( ! file_exists( $metadata_file ) ) { return false; } - $metadata = json_decode( file_get_contents( $file ), true ); - if ( ! is_array( $metadata ) ) { + $metadata = json_decode( file_get_contents( $metadata_file ), true ); + if ( ! is_array( $metadata ) || empty( $metadata['name'] ) ) { return false; } + $metadata['file'] = $metadata_file; + + $settings = array(); + $property_mappings = array( + 'title' => 'title', + 'category' => 'category', + 'parent' => 'parent', + 'icon' => 'icon', + 'description' => 'description', + 'keywords' => 'keywords', + 'attributes' => 'attributes', + 'supports' => 'supports', + 'styles' => 'styles', + 'example' => 'example', + ); + + foreach ( $property_mappings as $key => $mapped_key ) { + if ( isset( $metadata[ $key ] ) ) { + $settings[ $mapped_key ] = $metadata[ $key ]; + } + } + + if ( ! empty( $metadata['editorScript'] ) ) { + $settings['editor_script'] = register_block_script_handle( + $metadata, + 'editorScript' + ); + } + + if ( ! empty( $metadata['script'] ) ) { + $settings['script'] = register_block_script_handle( + $metadata, + 'script' + ); + } + + if ( ! empty( $metadata['editorStyle'] ) ) { + $settings['editor_style'] = register_block_style_handle( + $metadata, + 'editorStyle' + ); + } + + if ( ! empty( $metadata['style'] ) ) { + $settings['style'] = register_block_style_handle( + $metadata, + 'style' + ); + } return register_block_type( $metadata['name'], array_merge( - $metadata, + $settings, $args ) ); diff --git a/phpunit/class-register-block-type-from-metadata-test.php b/phpunit/class-register-block-type-from-metadata-test.php index e039c078d0164..81e240f5ad359 100644 --- a/phpunit/class-register-block-type-from-metadata-test.php +++ b/phpunit/class-register-block-type-from-metadata-test.php @@ -6,6 +6,120 @@ */ class Register_Block_Type_From_Metadata_Test extends WP_UnitTestCase { + + function test_does_not_remove_block_asset_path_prefix() { + $result = remove_block_asset_path_prefix( 'script-handle' ); + + $this->assertSame( 'script-handle', $result ); + } + + function test_removes_block_asset_path_prefix() { + $result = remove_block_asset_path_prefix( 'file:./block.js' ); + + $this->assertSame( './block.js', $result ); + } + + function test_generate_block_asset_handle() { + $block_name = 'unit-tests/my-block'; + + $this->assertSame( + 'unit-tests-my-block-editor-script', + generate_block_asset_handle( $block_name, 'editorScript' ) + ); + $this->assertSame( + 'unit-tests-my-block-script', + generate_block_asset_handle( $block_name, 'script' ) + ); + $this->assertSame( + 'unit-tests-my-block-editor-style', + generate_block_asset_handle( $block_name, 'editorStyle' ) + ); + $this->assertSame( + 'unit-tests-my-block-style', + generate_block_asset_handle( $block_name, 'style' ) + ); + } + + function test_field_not_found_register_block_script_handle() { + $result = register_block_script_handle( array(), 'script' ); + + $this->assertFalse( $result ); + } + + function test_empty_value_register_block_script_handle() { + $metadata = array( 'script' => '' ); + $result = register_block_script_handle( $metadata, 'script' ); + + $this->assertFalse( $result ); + } + + /** + * @expectedIncorrectUsage register_block_script_handle + */ + function test_missing_asset_file_register_block_script_handle() { + $metadata = array( + 'file' => __FILE__, + 'name' => 'unit-tests/test-block', + 'script' => 'file:./fixtures/missing-asset.js', + ); + $result = register_block_script_handle( $metadata, 'script' ); + + $this->assertFalse( $result ); + } + + function test_handle_passed_register_block_script_handle() { + $metadata = array( + 'editorScript' => 'test-script-handle', + ); + $result = register_block_script_handle( $metadata, 'editorScript' ); + + $this->assertSame( 'test-script-handle', $result ); + } + + function test_success_register_block_script_handle() { + $metadata = array( + 'file' => __FILE__, + 'name' => 'unit-tests/test-block', + 'script' => 'file:./fixtures/block.js', + ); + $result = register_block_script_handle( $metadata, 'script' ); + + $this->assertSame( 'unit-tests-test-block-script', $result ); + } + + function test_field_not_found_register_block_style_handle() { + $result = register_block_style_handle( array(), 'style' ); + + $this->assertFalse( $result ); + } + + function test_empty_value_found_register_block_style_handle() { + $metadata = array( 'style' => '' ); + $result = register_block_style_handle( $metadata, 'style' ); + + $this->assertFalse( $result ); + } + + function test_handle_passed_register_block_style_handle() { + $metadata = array( + 'style' => 'test-style-handle', + ); + $result = register_block_style_handle( $metadata, 'style' ); + + $this->assertSame( 'test-style-handle', $result ); + } + + function test_success_register_block_style_handle() { + $metadata = array( + 'file' => __FILE__, + 'name' => 'unit-tests/test-block', + 'style' => 'file:./fixtures/block.css', + ); + $result = register_block_style_handle( $metadata, 'style' ); + + $this->assertSame( 'unit-tests-test-block-style', $result ); + } + /** * Tests that the function returns false when the `block.json` is not found * in the WordPress core. @@ -32,14 +146,59 @@ function test_metadata_not_found_in_the_current_directory() { */ function test_block_registers_with_metadata_fixture() { $result = register_block_type_from_metadata( - __DIR__ . '/fixtures', - array( - 'foo' => 'bar', - ) + __DIR__ . '/fixtures' ); $this->assertInstanceOf( 'WP_Block_Type', $result ); - $this->assertEquals( 'test/block-name', $result->name ); - $this->assertEquals( 'bar', $result->foo ); + $this->assertSame( 'my-plugin/notice', $result->name ); + $this->assertSame( 'Notice', $result->title ); + $this->assertSame( 'common', $result->category ); + $this->assertEquals( array( 'core/group' ), $result->parent ); + $this->assertSame( 'star', $result->icon ); + $this->assertSame( 'Shows warning, error or success notices…', $result->description ); + $this->assertEquals( array( 'alert', 'message' ), $result->keywords ); + $this->assertEquals( + array( + 'message' => array( + 'type' => 'string', + 'source' => 'html', + 'selector' => '.message', + ), + ), + $result->attributes + ); + $this->assertEquals( + array( + 'align' => true, + 'lightBlockWrapper' => true, + ), + $result->supports + ); + $this->assertEquals( + array( + array( + 'name' => 'default', + 'label' => 'Default', + 'isDefault' => true, + ), + array( + 'name' => 'other', + 'label' => 'Other', + ), + ), + $result->styles + ); + $this->assertEquals( + array( + 'attributes' => array( + 'message' => 'This is a notice!', + ), + ), + $result->example + ); + $this->assertSame( 'my-plugin-notice-editor-script', $result->editor_script ); + $this->assertSame( 'my-plugin-notice-script', $result->script ); + $this->assertSame( 'my-plugin-notice-editor-style', $result->editor_style ); + $this->assertSame( 'my-plugin-notice-style', $result->style ); } } diff --git a/phpunit/class-wp-rest-block-types-controller-test.php b/phpunit/class-wp-rest-block-types-controller-test.php index 00b981c532ee0..91c921ea33d64 100644 --- a/phpunit/class-wp-rest-block-types-controller-test.php +++ b/phpunit/class-wp-rest-block-types-controller-test.php @@ -193,7 +193,7 @@ public function test_get_item_invalid() { 'keywords' => 'invalid_keywords', 'parent' => 'invalid_parent', 'supports' => 'invalid_supports', - 'styleVariations' => 'invalid_styles', + 'styles' => 'invalid_styles', 'render_callback' => 'invalid_callback', ); register_block_type( $block_type, $settings ); @@ -205,7 +205,7 @@ public function test_get_item_invalid() { $this->assertEqualSets( array( 'invalid_keywords' ), $data['keywords'] ); $this->assertEqualSets( array( 'invalid_parent' ), $data['parent'] ); $this->assertEqualSets( array(), $data['supports'] ); - $this->assertEqualSets( array(), $data['styles'] ); + $this->assertEqualSets( array( 'invalid_styles' ), $data['styles'] ); $this->assertEquals( false, $data['is_dynamic'] ); } diff --git a/phpunit/fixtures/block.asset.php b/phpunit/fixtures/block.asset.php new file mode 100644 index 0000000000000..792bbb6c84cd3 --- /dev/null +++ b/phpunit/fixtures/block.asset.php @@ -0,0 +1,6 @@ + array(), + 'version' => 'test', +); diff --git a/phpunit/fixtures/block.css b/phpunit/fixtures/block.css new file mode 100644 index 0000000000000..5bbe1134f7048 --- /dev/null +++ b/phpunit/fixtures/block.css @@ -0,0 +1 @@ +/* Test CSS file */ diff --git a/phpunit/fixtures/block.js b/phpunit/fixtures/block.js new file mode 100644 index 0000000000000..0bdf0f5ad91f7 --- /dev/null +++ b/phpunit/fixtures/block.js @@ -0,0 +1 @@ +/* Test JavaScript file. */ diff --git a/phpunit/fixtures/block.json b/phpunit/fixtures/block.json index 3da9b88d722a2..2d71b8f26a4dc 100644 --- a/phpunit/fixtures/block.json +++ b/phpunit/fixtures/block.json @@ -1,4 +1,46 @@ { - "name": "test/block-name", - "category": "widgets" + "name": "my-plugin/notice", + "title": "Notice", + "category": "common", + "parent": [ + "core/group" + ], + "icon": "star", + "description": "Shows warning, error or success notices…", + "keywords": [ + "alert", + "message" + ], + "textDomain": "my-plugin", + "attributes": { + "message": { + "type": "string", + "source": "html", + "selector": ".message" + } + }, + "supports": { + "align": true, + "lightBlockWrapper": true + }, + "styles": [ + { + "name": "default", + "label": "Default", + "isDefault": true + }, + { + "name": "other", + "label": "Other" + } + ], + "example": { + "attributes": { + "message": "This is a notice!" + } + }, + "editorScript": "my-plugin-notice-editor-script", + "script": "my-plugin-notice-script", + "editorStyle": "my-plugin-notice-editor-style", + "style": "my-plugin-notice-style" }