diff --git a/lib/blocks.php b/lib/blocks.php index e28971bad032c2..d1c5871486dec3 100644 --- a/lib/blocks.php +++ b/lib/blocks.php @@ -115,6 +115,27 @@ function gutenberg_render_block( $block ) { return ''; } +/** + * Prefixes the core namespace ('core/') to the block type name if the namespace isn't found in the block type name + * + * @since 2.7.0 + * + * @param string $block_type Name of the block type. + * @return string Name of the block type, prefixed by the core namespace if needed. + */ +function gutenberg_normalize_block_type( $block_type ) { + $block_type = trim( $block_type ); + + $index_of_slash = strpos( $block_type, '/' ); + + // If namespace isn't found in the block type name, prefix it with 'core/'. + if ( false === $index_of_slash ) { + return 'core/' . $block_type; + } + + return $block_type; +} + /** * Parses dynamic blocks out of `post_content` and re-renders them. * @@ -123,7 +144,8 @@ function gutenberg_render_block( $block ) { * @param string $content Post content. * @return string Updated post content. */ -function do_blocks( $content ) { +function gutenberg_render_dynamic_blocks( $content ) { + $rendered_content = ''; $dynamic_block_names = get_dynamic_block_names(); @@ -154,15 +176,14 @@ function do_blocks( $content ) { } // Since content is a working copy since the last match, append to - // rendered content up to the matched offset... - $rendered_content .= substr( $content, 0, $offset ); + // rendered content up to the matched offset (including the opening tag)... + $rendered_content .= substr( $content, 0, $offset + strlen( $opening_tag ) ); // ...then update the working copy of content. $content = substr( $content, $offset + strlen( $opening_tag ) ); // Make implicit core namespace explicit. - $is_implicit_core_namespace = ( false === strpos( $block_name, '/' ) ); - $normalized_block_name = $is_implicit_core_namespace ? 'core/' . $block_name : $block_name; + $normalized_block_name = gutenberg_normalize_block_type( $block_name ); // Find registered block type. We can assume it exists since we use the // `get_dynamic_block_names` function as a source for pattern matching. @@ -188,20 +209,97 @@ function do_blocks( $content ) { break; } - // Update content to omit text up to and including closing tag. + // Update content to omit text up to the closing tag. $end_tag = $block_match_end[0][0]; $end_offset = $block_match_end[0][1]; - $content = substr( $content, $end_offset + strlen( $end_tag ) ); + $content = substr( $content, $end_offset ); } } // Append remaining unmatched content. $rendered_content .= $content; - // Strip remaining block comment demarcations. - $rendered_content = preg_replace( '/\r?\n?/m', '', $rendered_content ); - return $rendered_content; } -add_filter( 'the_content', 'do_blocks', 9 ); // BEFORE do_shortcode(). + +/** + * For a single post / page, this function parses `block types` while stripping + * `block comments` from the post's HTML and calls WP_Parsed_Block_Types_Registry + * to save those parsed block types. It registers gutenberg_process_block_comment() + * as a callback function for preg_replace_callback(). For each block comment + * (matched by the Regex) in the post's HTML, gutenberg_process_block_comment() + * will get called. + * + * For pages containing multiple posts like category / archive / home page, this only strips + * block comments (doesn't save the parsed block types in WP_Parsed_Block_Types_Registry). + * We don't need to save `parsed block types` for such pages since intelligent enqueuing of + * front-end styles (only enqueue styles for `block types` present in the post/page) + * only needs to happen for single posts / pages as of now. + * + * @since 2.7.0 + * + * @param string $content Post content. + * @return string Updated post content (without block comments) + */ +function gutenberg_process_block_comments( $content ) { + + // Checks if a single page/post is requested. + $is_post_or_page = is_singular( array( 'post', 'page' ) ); + + /* + * If a single page/post is requested, we need to parse and save `parsed block types` while stripping block comments. + * Otherwise, for category / archive / home page etc, we can simply strip block comments and return the HTML. + */ + + if ( $is_post_or_page ) { + $content = preg_replace_callback( '/\r?\n?/m', 'gutenberg_process_block_comment', $content ); + } else { + $content = preg_replace( '/\r?\n?/m', '', $content ); + } + + return $content; +} + +/** + * Registered as a callback function to preg_replace_callback() and is called once for each + * block comment parsed from the post's HTML. It returns an empty string for each instantiation + * so that the post's HTML gets stripped of block comments. + * + * Also, it uses WP_Parsed_Block_Types_Registry to store block types parsed from the block comments. + * Those can be later used if needed. + * + * @since 2.7.0 + * + * @param string $matches An array filled with the results of search. + * $matches[0] will contain the text that matched the full pattern, + * $matches[1] will have the text that matched the first captured parenthesized subpattern, + * and so on. + * @return string Returns an empty string to preg_replace_callback() for each of the block comments + */ +function gutenberg_process_block_comment( $matches ) { + + $block_comment = $matches[0]; + + // Only process the block comment if it's not a closing tag for a block. If it's a closing tag, we can just return. + if ( preg_match( '/\/wp:/m', $block_comment ) !== 1 ) { + + preg_match( '/wp:(.*?)\s+/m', $block_comment, $match ); + + $block_type_name = $match[1]; + $block_type_name = gutenberg_normalize_block_type( $block_type_name ); + + WP_Parsed_Block_Types_Registry::get_instance()->add( $block_type_name ); + } + + return ''; +} + +add_filter( 'the_content', 'gutenberg_render_dynamic_blocks', 9 ); // BEFORE do_shortcode(). + +/* + * This needs to run as late as possible so as to let plugins apply filters to content first. + * Have subtracted 1 from the priority since `enqueue_required_frontend_block_styles()` + * needs to run after this. Have given that filter PHP_INT_MAX priority. + */ +add_filter( 'the_content', 'gutenberg_process_block_comments', PHP_INT_MAX - 1 ); diff --git a/lib/class-wp-block-type-registry.php b/lib/class-wp-block-type-registry.php index 2e91b53933a72b..88cd253fabd7bf 100644 --- a/lib/class-wp-block-type-registry.php +++ b/lib/class-wp-block-type-registry.php @@ -56,22 +56,13 @@ public function register( $name, $args = array() ) { $name = $block_type->name; } - if ( ! is_string( $name ) ) { - $message = __( 'Block type names must be strings.', 'gutenberg' ); - _doing_it_wrong( __METHOD__, $message, '0.1.0' ); - return false; - } + $block_type_validator = new WP_Block_Type_Validator(); + $is_block_type_valid = $block_type_validator->validate( $name ); - if ( preg_match( '/[A-Z]+/', $name ) ) { - $message = __( 'Block type names must not contain uppercase characters.', 'gutenberg' ); - _doing_it_wrong( __METHOD__, $message, '1.5.0' ); - return false; - } + if ( ! $is_block_type_valid ) { + $error = $block_type_validator->get_last_error(); + _doing_it_wrong( __METHOD__, $error['error_text'], $error['added_from_version'] ); - $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', 'gutenberg' ); - _doing_it_wrong( __METHOD__, $message, '0.1.0' ); return false; } diff --git a/lib/class-wp-block-type-validator.php b/lib/class-wp-block-type-validator.php new file mode 100644 index 00000000000000..ed51bf6e7d5977 --- /dev/null +++ b/lib/class-wp-block-type-validator.php @@ -0,0 +1,124 @@ +name; + } + + if ( ! is_string( $name ) ) { + $message = __( 'Block type names must be strings.', 'gutenberg' ); + $this->set_error( $message, '0.1.0' ); + + return false; + } + + if ( preg_match( '/[A-Z]+/', $name ) ) { + $message = __( 'Block type names must not contain uppercase characters.', 'gutenberg' ); + $this->set_error( $message, '1.5.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', 'gutenberg' ); + $this->set_error( $message, '0.1.0' ); + + return false; + } + + return true; + } + + /** + * Set an error in the validator + * + * This function can be used to set an error in the Validator + * Please note that this function adds the error to the existing $this->errors array + * It does not flush the existing errors object + * + * @param string $error_text A string denoting the error message. + * @param string $added_from_version A string denoting the version of WordPress where the error message was added. + */ + public function set_error( $error_text, $added_from_version = '' ) { + $this->errors[] = array( + 'error_text' => $error_text, + 'added_from_version' => $added_from_version, + ); + } + + /** + * Checks if the Validator encountered any errors in validation + * + * This function checks $this->errors array and returns true if there are any errors in it. + * If yes, it returns true. If no, it returns false. + * + * @return bool True/False + */ + public function has_errors() { + return ! empty( $this->errors ) ? true : false; + } + + /** + * Get errors stored in the Validator + * + * This function returns the $this->errors array + * + * @return array An array is returned containing the errors stored in the Validator. + * The returned array is an array of errors. + * Each individual error is an array with `error_text` and `added_from_version` keys. + * If there are no errors stored, an empty array is returned + */ + public function get_errors() { + return $this->errors; + } + + /** + * Get the last error encountered by the Validator + * + * @return array|bool An array containing `error_text` and `added_from_version` keys + * If there are no errors stored in the validator, FALSE (boolean) is returned + */ + public function get_last_error() { + $error_count = count( $this->errors ); + + if ( 0 === $error_count ) { + return false; + } else { + return $this->errors[ $error_count - 1 ]; + } + } +} diff --git a/lib/class-wp-parsed-block-types-registry.php b/lib/class-wp-parsed-block-types-registry.php new file mode 100644 index 00000000000000..695324f5845623 --- /dev/null +++ b/lib/class-wp-parsed-block-types-registry.php @@ -0,0 +1,128 @@ +name; + } + + $block_type_validator = new WP_Block_Type_Validator(); + $is_block_type_valid = $block_type_validator->validate( $block_type ); + + if ( ! $is_block_type_valid ) { + $error = $block_type_validator->get_last_error(); + _doing_it_wrong( __METHOD__, $error['error_text'], $error['added_from_version'] ); + + return false; + } + + if ( ! WP_Block_Type_Registry::get_instance()->is_registered( $block_type ) ) { + // translators: 1: block name. + $message = sprintf( __( 'Block type "%s" isn\'t registered yet.', 'gutenberg' ), $block_type ); + _doing_it_wrong( __METHOD__, $message, '0.1.0' ); + return false; + } + + if ( ! in_array( $block_type, $this->block_types_in_current_page ) ) { + array_push( $this->block_types_in_current_page, $block_type ); + } + + return true; + } + + /** + * Retrieves all block types present in the web page being currently served + * + * @since 2.6.0 + * @access public + * + * @return array Array of block types present on the web page being currently served (As an array of strings) + */ + public function get_block_types_in_current_page() { + return $this->block_types_in_current_page; + } + + /** + * Checks if a block type is present on the current web page + * + * @since 2.6.0 + * @access public + * + * @param string $block_type Block type name including namespace. + * @return bool True if the block type is present, false otherwise. + */ + public function is_block_type_present_on_current_page( $block_type ) { + return isset( $this->block_types_in_current_page[ $block_type ] ); + } + + /** + * Utility method to retrieve the main instance of the class. + * + * The instance will be created if it does not exist yet. + * + * @since 2.6.0 + * @access public + * @static + * + * @return WP_Parsed_Block_Types_Registry The main instance. + */ + public static function get_instance() { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } +} diff --git a/lib/client-assets.php b/lib/client-assets.php index bb52445e67ead3..10b21f4c9def32 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -342,6 +342,7 @@ function gutenberg_register_scripts_and_styles() { filemtime( gutenberg_dir_path() . 'plugins/build/index.js' ) ); } + add_action( 'wp_enqueue_scripts', 'gutenberg_register_scripts_and_styles', 5 ); add_action( 'admin_enqueue_scripts', 'gutenberg_register_scripts_and_styles', 5 ); @@ -746,12 +747,21 @@ function gutenberg_common_scripts_and_styles() { * @since 2.0.0 */ function gutenberg_enqueue_registered_block_scripts_and_styles() { - $is_editor = ( 'enqueue_block_editor_assets' === current_action() ); + + $is_editor = ( 'enqueue_block_editor_assets' === current_action() ); + $is_front_end = ! $is_editor; + + $is_post_or_page = is_singular( array( 'post', 'page' ) ); + + $enqueue_only_required_styles = $is_front_end && $is_post_or_page; + $enqueue_styles_for_all_blocks = ! $enqueue_only_required_styles; $block_registry = WP_Block_Type_Registry::get_instance(); + foreach ( $block_registry->get_all_registered() as $block_name => $block_type ) { + // Front-end styles. - if ( ! empty( $block_type->style ) ) { + if ( ! empty( $block_type->style ) && $enqueue_styles_for_all_blocks ) { wp_enqueue_style( $block_type->style ); } @@ -770,10 +780,58 @@ function gutenberg_enqueue_registered_block_scripts_and_styles() { wp_enqueue_script( $block_type->editor_script ); } } + + if ( $enqueue_only_required_styles ) { + /* + * This needs to run after `gutenberg_process_block_comments()` (which attempts to run as + * late as it can). Since `gutenberg_process_block_comments()` has (PHP_INT_MAX - 1) priority, + * therefore this has been assigned PHP_INT_MAX priority. + */ + add_filter( 'the_content', 'enqueue_required_frontend_block_styles', PHP_INT_MAX ); + } } + add_action( 'enqueue_block_assets', 'gutenberg_enqueue_registered_block_scripts_and_styles' ); add_action( 'enqueue_block_editor_assets', 'gutenberg_enqueue_registered_block_scripts_and_styles' ); +/** + * Enqueues frontend styles for block types present in the current page/post. + * This is hooked as a filter to 'the_content' and is executed after block comments + * are stripped from the HTML. + * + * It doesn't actually filter the post's content. It returns the post's content as is. + * It's only hooked as a filter so that it gets executed right after block comments are + * stripped from the HTML. This will make sure that we enqueue styles as early as we can. + * + * @since 2.7.0 + * + * @param string $content Post content. + * @return string Post content returned as-is. + */ +function enqueue_required_frontend_block_styles( $content ) { + + $all_block_types_registry = WP_Block_Type_Registry::get_instance(); + $parsed_block_types_registry = WP_Parsed_Block_Types_Registry::get_instance(); + + $block_types_in_current_page = $parsed_block_types_registry->get_block_types_in_current_page(); + + foreach ( $block_types_in_current_page as $block_type_name ) { + + $block_type = $all_block_types_registry->get_registered( $block_type_name ); + + if ( ! isset( $block_type ) ) { + + $error_message = sprintf( __( 'Cannot enqueue front-end styles for "%s" block type since it isn\'t registered.', 'gutenberg' ), $block_type_name ); + trigger_error( $error_message, E_USER_NOTICE ); + + } elseif ( isset( $block_type->style ) ) { + wp_enqueue_style( $block_type->style ); + } + } + + return $content; +} + /** * The code editor settings that were last captured by * gutenberg_capture_code_editor_settings(). diff --git a/lib/load.php b/lib/load.php index 0da31fddf577cc..bff46a8c432fa8 100644 --- a/lib/load.php +++ b/lib/load.php @@ -11,7 +11,9 @@ require dirname( __FILE__ ) . '/meta-box-partial-page.php'; require dirname( __FILE__ ) . '/class-wp-block-type.php'; +require dirname( __FILE__ ) . '/class-wp-block-type-validator.php'; require dirname( __FILE__ ) . '/class-wp-block-type-registry.php'; +require dirname( __FILE__ ) . '/class-wp-parsed-block-types-registry.php'; require dirname( __FILE__ ) . '/class-wp-rest-blocks-controller.php'; require dirname( __FILE__ ) . '/blocks.php'; require dirname( __FILE__ ) . '/client-assets.php'; diff --git a/phpunit/class-do-blocks-test.php b/phpunit/class-do-blocks-test.php deleted file mode 100644 index ec7cd97c39bbdf..00000000000000 --- a/phpunit/class-do-blocks-test.php +++ /dev/null @@ -1,25 +0,0 @@ -assertEquals( $expected_html, $actual_html ); - } -} diff --git a/phpunit/class-dynamic-blocks-render-test.php b/phpunit/class-dynamic-blocks-render-test.php index a642184fc99a5b..81801696028712 100644 --- a/phpunit/class-dynamic-blocks-render-test.php +++ b/phpunit/class-dynamic-blocks-render-test.php @@ -53,7 +53,7 @@ function tearDown() { /** * Test dynamic blocks that lack content, including void blocks. * - * @covers ::do_blocks + * @covers ::gutenberg_render_dynamic_blocks */ function test_dynamic_block_rendering() { $settings = array( @@ -74,14 +74,14 @@ function test_dynamic_block_rendering() { '' . 'after'; - $updated_post_content = do_blocks( $post_content ); + $updated_post_content = gutenberg_render_dynamic_blocks( $post_content ); $this->assertEquals( $updated_post_content, 'before' . - '1:b1' . - '2:b1' . + '' . '1:b1' . '' . + '' . '2:b1' . '' . 'between' . - '3:b2' . - '4:b2' . + '' . '3:b2' . + '' . '4:b2' . 'after' ); } diff --git a/phpunit/class-strip-block-comments-test.php b/phpunit/class-strip-block-comments-test.php new file mode 100644 index 00000000000000..520129bf7786fb --- /dev/null +++ b/phpunit/class-strip-block-comments-test.php @@ -0,0 +1,25 @@ +assertEquals( $expected_html, $actual_html ); + } +}