diff --git a/src/wp-includes/block-supports/auto-register.php b/src/wp-includes/block-supports/auto-register.php index 83feedaaeeade..007911732c1e3 100644 --- a/src/wp-includes/block-supports/auto-register.php +++ b/src/wp-includes/block-supports/auto-register.php @@ -59,3 +59,130 @@ function wp_mark_auto_generate_control_attributes( array $args ): array { // Priority 5 to mark original attributes before other filters (priority 10+) might add their own. add_filter( 'register_block_type_args', 'wp_mark_auto_generate_control_attributes', 5 ); + +/** + * Configures server-side rendering for auto-registered blocks with a pattern. + * + * @since 7.1.0 + * @access private + * + * @param array $args Arguments passed to `register_block_type()`. + * @param string $block_name Block type name. + * @return array Filtered arguments. + */ +function wp_apply_pattern_block_rendering( array $args, string $block_name ): array { + if ( + empty( $args['supports']['autoRegister'] ) || + ! is_string( $args['pattern'] ?? null ) || + '' === $args['pattern'] + ) { + return $args; + } + + if ( ! empty( $args['render_callback'] ) ) { + _doing_it_wrong( + 'register_block_type', + sprintf( + /* translators: %s: Block name. */ + __( 'Block type "%s" was registered with both a pattern and a render callback. The pattern takes precedence, so the render callback is ignored.' ), + $block_name + ), + '7.1.0' + ); + } + + // Pattern overrides use `content`, so replace any existing schema. + $args['attributes']['content'] = array( 'type' => 'object' ); + if ( ! isset( $args['provides_context'] ) ) { + $args['provides_context'] = array(); + } + $args['provides_context']['pattern/overrides'] = 'content'; + + // Disable HTML editing by default because the pattern is not saved with the block. + if ( ! isset( $args['supports']['html'] ) ) { + $args['supports']['html'] = false; + } + + // Ignore inner blocks from saved content because only the registered pattern should render. + $args['skip_inner_blocks'] = true; + $args['render_callback'] = static function ( $attributes, $content, $block ) { + // A pattern can contain another instance of the same block. Render that nested + // instance as empty, matching `core/block`, to avoid infinite recursion. + static $rendering = array(); + if ( isset( $rendering[ $block->name ] ) ) { + return ''; + } + $rendering[ $block->name ] = true; + + // Read the current pattern at render time, then rebuild its children so they + // inherit the host block's override context. + $pattern = $block->block_type->pattern; + if ( ! is_string( $pattern ) || '' === $pattern ) { + $pattern = ''; + } + + // `WP_Embed` processes embeds on `the_content` before `do_blocks()` runs, + // so the pattern, injected during `do_blocks()`, never goes through those + // filters and its embed URLs would render as plain links. + global $wp_embed; + $pattern = $wp_embed->run_shortcode( $pattern ); + $pattern = $wp_embed->autoembed( $pattern ); + + $block->parsed_block['innerBlocks'] = parse_blocks( $pattern ); + $block->parsed_block['innerContent'] = array_fill( + 0, + count( $block->parsed_block['innerBlocks'] ), + null + ); + // `refresh_context_dependents()` does not clear existing inner blocks + // when the new parsed list is empty. + $block->inner_blocks = array(); + $block->refresh_context_dependents(); + + // Apply the same child filters as `WP_Block::render()` without rendering the + // host block or applying its `render_block` filters a second time. + $output = ''; + foreach ( $block->inner_blocks as $inner_block ) { + /** This filter is documented in wp-includes/blocks.php */ + $pre_render = apply_filters( 'pre_render_block', null, $inner_block->parsed_block, $block ); + if ( null !== $pre_render ) { + $output .= $pre_render; + continue; + } + + $source_block = $inner_block->parsed_block; + $inner_block_context = $inner_block->context; + + /** This filter is documented in wp-includes/blocks.php */ + $inner_block->parsed_block = apply_filters( + 'render_block_data', + $inner_block->parsed_block, + $source_block, + $block + ); + + /** This filter is documented in wp-includes/blocks.php */ + $inner_block->context = apply_filters( + 'render_block_context', + $inner_block->context, + $inner_block->parsed_block, + $block + ); + + if ( $inner_block->context !== $inner_block_context ) { + $inner_block->refresh_context_dependents(); + } elseif ( $inner_block->parsed_block !== $source_block ) { + $inner_block->refresh_parsed_block_dependents(); + } + + $output .= $inner_block->render(); + } + + unset( $rendering[ $block->name ] ); + + return sprintf( '
%2$s
', get_block_wrapper_attributes(), $output ); + }; + + return $args; +} +add_filter( 'register_block_type_args', 'wp_apply_pattern_block_rendering', 10, 2 ); diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index fe1b1a64fb964..9c56f6c2ca548 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -3183,22 +3183,32 @@ function _wp_footnotes_force_filtered_html_on_import_filter( $arg ) { } /** - * Exposes blocks with autoRegister flag for ServerSideRender in the editor. + * Exposes auto-registered blocks to the editor. * - * Detects blocks that have the autoRegister flag set in their supports - * and passes them to JavaScript for auto-registration with ServerSideRender. + * Passes the names of blocks with render callbacks and the markup for blocks + * registered with patterns to JavaScript. * * @access private * @since 7.0.0 + * @since 7.1.0 Added pattern markup to the auto-registration data. */ function _wp_enqueue_auto_register_blocks() { - $auto_register_blocks = array(); - $registered_blocks = WP_Block_Type_Registry::get_instance()->get_all_registered(); + $auto_register_blocks = array(); + $auto_register_block_patterns = array(); + $registered_blocks = WP_Block_Type_Registry::get_instance()->get_all_registered(); foreach ( $registered_blocks as $block_name => $block_type ) { if ( ! empty( $block_type->supports['autoRegister'] ) && ! empty( $block_type->render_callback ) ) { $auto_register_blocks[] = $block_name; } + + if ( + ! empty( $block_type->supports['autoRegister'] ) && + is_string( $block_type->pattern ) && + '' !== $block_type->pattern + ) { + $auto_register_block_patterns[ $block_name ] = $block_type->pattern; + } } if ( ! empty( $auto_register_blocks ) ) { @@ -3208,4 +3218,12 @@ function _wp_enqueue_auto_register_blocks() { 'before' ); } + + if ( ! empty( $auto_register_block_patterns ) ) { + wp_add_inline_script( + 'wp-block-library', + sprintf( 'window.__unstableAutoRegisterBlockPatterns = %s;', wp_json_encode( $auto_register_block_patterns ) ), + 'before' + ); + } } diff --git a/src/wp-includes/class-wp-block-type.php b/src/wp-includes/class-wp-block-type.php index 86f0ea21a2a3c..40d4cc7375b7e 100644 --- a/src/wp-includes/class-wp-block-type.php +++ b/src/wp-includes/class-wp-block-type.php @@ -166,6 +166,14 @@ class WP_Block_Type { */ public $render_callback = null; + /** + * Serialized block pattern used to render the block. + * + * @since 7.1.0 + * @var string|null + */ + public $pattern = null; + /** * Block type attributes property schemas. * @@ -303,6 +311,7 @@ class WP_Block_Type { * @since 6.3.0 Added the `selectors` property. * @since 6.4.0 Added the `block_hooks` property. * @since 6.5.0 Added the `allowed_blocks`, `variation_callback`, and `view_style_handles` properties. + * @since 7.1.0 Added the `pattern` property. * * @see register_block_type() * @@ -331,6 +340,7 @@ class WP_Block_Type { * @type array|null $supports Supported features. * @type array|null $example Structured data for the block preview. * @type callable|null $render_callback Block type render callback. + * @type string|null $pattern Serialized block pattern used to render the block. * @type callable|null $variation_callback Block type variations callback. * @type array|null $attributes Block type attributes property schemas. * @type string[] $uses_context Context values inherited by blocks of this type. diff --git a/tests/phpunit/tests/block-supports/auto-register.php b/tests/phpunit/tests/block-supports/auto-register.php index 71754f247225b..27992dec1851a 100644 --- a/tests/phpunit/tests/block-supports/auto-register.php +++ b/tests/phpunit/tests/block-supports/auto-register.php @@ -2,10 +2,112 @@ /** * @group block-supports * + * @covers ::_wp_enqueue_auto_register_blocks + * @covers ::wp_apply_pattern_block_rendering * @covers ::wp_mark_auto_generate_control_attributes */ class Tests_Block_Supports_Auto_Register extends WP_UnitTestCase { + const BLOCK_NAME = 'tests/pattern-block'; + + const DYNAMIC_CHILD_BLOCK_NAME = 'tests/pattern-block-dynamic-child'; + + const PATTERN = '

Default title

' + . '

Plugin-owned paragraph.

'; + + /** + * Original scripts instance. + * + * @var WP_Scripts|null + */ + protected $original_wp_scripts; + + /** + * Block types registered during a test. + * + * @var string[] + */ + private $registered_block_names = array(); + + public function set_up() { + parent::set_up(); + + global $wp_scripts; + $this->original_wp_scripts = $wp_scripts; + $wp_scripts = null; + wp_scripts(); + } + + public function tear_down() { + foreach ( array_reverse( $this->registered_block_names ) as $block_name ) { + if ( WP_Block_Type_Registry::get_instance()->is_registered( $block_name ) ) { + unregister_block_type( $block_name ); + } + } + + global $wp_scripts; + $wp_scripts = $this->original_wp_scripts; + + WP_Theme_JSON_Resolver::clean_cached_data(); + + parent::tear_down(); + } + + /** + * Registers a block and records it for cleanup. + * + * @param string $block_name Block type name. + * @param array $args Arguments for registering the block type. + * @return WP_Block_Type|false Registered block type on success, false on failure. + */ + private function register_test_block( $block_name, $args ) { + $block_type = register_block_type( $block_name, $args ); + + if ( $block_type ) { + $this->registered_block_names[] = $block_name; + } + + return $block_type; + } + + /** + * Registers the test pattern block. + * + * @param array $extra_args Extra `register_block_type()` arguments. + * @return WP_Block_Type|false Registered block type on success, false on failure. + */ + private function register_pattern_block( $extra_args = array() ) { + return $this->register_test_block( + self::BLOCK_NAME, + array_merge( + array( + 'supports' => array( 'autoRegister' => true ), + 'pattern' => self::PATTERN, + ), + $extra_args + ) + ); + } + + /** + * Returns data from an auto-registration bootstrap global. + * + * @param string $global_name JavaScript global name, without the `window.` prefix. + * @return array|null Decoded bootstrap data, or null if the global was not added. + */ + private function get_auto_register_bootstrap_data( $global_name ) { + $inline_scripts = wp_scripts()->get_data( 'wp-block-library', 'before' ); + $prefix = 'window.' . $global_name . ' = '; + + foreach ( (array) $inline_scripts as $inline_script ) { + if ( 0 === strpos( $inline_script, $prefix ) ) { + return json_decode( substr( $inline_script, strlen( $prefix ), -1 ), true ); + } + } + + return null; + } + /** * Tests that attributes are marked when autoRegister is enabled. * @@ -141,4 +243,346 @@ public function test_excludes_unsupported_types() { $this->assertArrayNotHasKey( 'autoGenerateControl', $result['attributes']['config'] ); $this->assertArrayNotHasKey( 'autoGenerateControl', $result['attributes']['unknown'] ); } + + /** + * @ticket 65628 + */ + public function test_renders_the_pattern_inside_the_block_wrapper() { + $this->register_pattern_block(); + + $output = do_blocks( '' ); + + $this->assertStringContainsString( 'Default title', $output ); + $this->assertStringContainsString( 'Plugin-owned paragraph.', $output ); + $this->assertStringContainsString( 'wp-block-tests-pattern-block', $output ); + } + + /** + * @ticket 65628 + */ + public function test_renders_instance_overrides_for_bound_fields() { + $this->register_pattern_block(); + + $output = do_blocks( '' ); + + $this->assertStringContainsString( 'Overridden title', $output ); + $this->assertStringNotContainsString( 'Default title', $output ); + $this->assertStringContainsString( 'Plugin-owned paragraph.', $output ); + } + + /** + * @ticket 65628 + */ + public function test_ignores_saved_inner_content_without_rendering_dynamic_children() { + $render_calls = 0; + $this->register_test_block( + self::DYNAMIC_CHILD_BLOCK_NAME, + array( + 'render_callback' => static function () use ( &$render_calls ) { + ++$render_calls; + return '

DYNAMIC SAVED CHILD

'; + }, + ) + ); + $this->register_pattern_block(); + + $output = do_blocks( + '' + . '

INJECTED

' + . '' + . '' + ); + + $this->assertSame( 0, $render_calls ); + $this->assertStringNotContainsString( 'INJECTED', $output ); + $this->assertStringNotContainsString( 'DYNAMIC SAVED CHILD', $output ); + $this->assertStringContainsString( 'Plugin-owned paragraph.', $output ); + } + + /** + * @ticket 65628 + */ + public function test_registering_a_render_callback_with_a_pattern_raises_a_notice_and_is_ignored() { + $this->setExpectedIncorrectUsage( 'register_block_type' ); + + $this->register_pattern_block( + array( + 'render_callback' => static function () { + return '

CALLBACK OUTPUT

'; + }, + ) + ); + + $output = do_blocks( '' ); + + $this->assertStringNotContainsString( 'CALLBACK OUTPUT', $output ); + $this->assertStringContainsString( 'Plugin-owned paragraph.', $output ); + } + + /** + * @ticket 65628 + */ + public function test_replaces_an_author_declared_content_attribute() { + $this->register_pattern_block( + array( + 'attributes' => array( + 'content' => array( 'type' => 'string' ), + ), + ) + ); + + $output = do_blocks( '' ); + + $this->assertStringContainsString( 'Overridden title', $output ); + } + + /** + * @ticket 65628 + */ + public function test_renders_the_pattern_current_at_render_time() { + $this->register_pattern_block(); + + WP_Block_Type_Registry::get_instance()->get_registered( self::BLOCK_NAME )->pattern = + '

Replaced pattern.

'; + + $output = do_blocks( '' ); + + $this->assertStringContainsString( 'Replaced pattern.', $output ); + $this->assertStringNotContainsString( 'Default title', $output ); + } + + /** + * @ticket 65628 + */ + public function test_embeds_urls_in_the_pattern() { + $this->register_pattern_block( + array( + 'pattern' => '' + . '
' . "\n" + . 'https://example.com/video' . "\n" + . '
' + . '', + ) + ); + + $expected_embed = ''; + $pre_oembed = static function () use ( $expected_embed ) { + return $expected_embed; + }; + add_filter( 'pre_oembed_result', $pre_oembed ); + + $output = do_blocks( '' ); + remove_filter( 'pre_oembed_result', $pre_oembed ); + + $this->assertStringContainsString( $expected_embed, $output ); + } + + /** + * @ticket 65628 + */ + public function test_processes_embed_shortcodes_in_the_pattern() { + $this->register_pattern_block( + array( + 'pattern' => '[embed]https://example.com/shortcode-video[/embed]', + ) + ); + + $expected_embed = ''; + $pre_oembed = static function () use ( $expected_embed ) { + return $expected_embed; + }; + add_filter( 'pre_oembed_result', $pre_oembed ); + + $output = do_blocks( '' ); + remove_filter( 'pre_oembed_result', $pre_oembed ); + + $this->assertStringContainsString( $expected_embed, $output ); + } + + /** + * @ticket 65628 + */ + public function test_does_not_render_saved_inner_content_when_current_pattern_is_empty() { + $render_calls = 0; + $this->register_test_block( + self::DYNAMIC_CHILD_BLOCK_NAME, + array( + 'render_callback' => static function () use ( &$render_calls ) { + ++$render_calls; + return '

DYNAMIC SAVED CHILD

'; + }, + ) + ); + $this->register_pattern_block(); + + WP_Block_Type_Registry::get_instance()->get_registered( self::BLOCK_NAME )->pattern = ''; + + $output = do_blocks( + '' + . '

INJECTED

' + . '' + . '' + ); + + $this->assertSame( 0, $render_calls ); + $this->assertStringNotContainsString( 'INJECTED', $output ); + $this->assertStringNotContainsString( 'DYNAMIC SAVED CHILD', $output ); + $this->assertStringNotContainsString( 'Plugin-owned paragraph.', $output ); + } + + /** + * @ticket 65628 + */ + public function test_applies_the_host_render_block_filters_once() { + $this->register_pattern_block(); + + $filter_calls = 0; + $count_calls = static function ( $block_content ) use ( &$filter_calls ) { + ++$filter_calls; + return $block_content; + }; + add_filter( 'render_block_' . self::BLOCK_NAME, $count_calls ); + + do_blocks( '' ); + remove_filter( 'render_block_' . self::BLOCK_NAME, $count_calls ); + + $this->assertSame( 1, $filter_calls ); + } + + /** + * @ticket 65628 + */ + public function test_applies_the_per_child_render_filters_to_pattern_roots() { + $this->register_pattern_block(); + + $roots_seen = array(); + $collect = static function ( $pre_render, $parsed_block, $parent_block ) use ( &$roots_seen ) { + if ( $parent_block instanceof WP_Block && self::BLOCK_NAME === $parent_block->name ) { + $roots_seen[] = $parsed_block['blockName']; + } + return $pre_render; + }; + add_filter( 'pre_render_block', $collect, 10, 3 ); + + do_blocks( '' ); + remove_filter( 'pre_render_block', $collect ); + + $this->assertSame( array( 'core/heading', 'core/paragraph' ), $roots_seen ); + } + + /** + * @ticket 65628 + */ + public function test_renders_a_self_referencing_pattern_without_recursing() { + $this->register_pattern_block( + array( + 'pattern' => '

Outer content.

' + . '', + ) + ); + + $output = do_blocks( '' ); + + $this->assertSame( 1, substr_count( $output, 'Outer content.' ) ); + } + + /** + * @ticket 65628 + */ + public function test_disables_html_support_by_default_for_pattern_blocks() { + $block_type = $this->register_pattern_block(); + + $this->assertFalse( $block_type->supports['html'] ); + } + + /** + * @ticket 65628 + */ + public function test_preserves_explicit_html_support_for_pattern_blocks() { + $block_type = $this->register_pattern_block( + array( + 'supports' => array( + 'autoRegister' => true, + 'html' => true, + ), + ) + ); + + $this->assertTrue( $block_type->supports['html'] ); + } + + /** + * @ticket 65628 + */ + public function test_enqueues_exact_pattern_markup_for_eligible_blocks() { + $pattern = '

Markup & spacing.

'; + $this->register_test_block( + 'tests/auto-register-eligible-pattern', + array( + 'supports' => array( 'autoRegister' => true ), + 'pattern' => $pattern, + ) + ); + + _wp_enqueue_auto_register_blocks(); + $patterns = $this->get_auto_register_bootstrap_data( '__unstableAutoRegisterBlockPatterns' ); + + $this->assertIsArray( $patterns ); + $this->assertArrayHasKey( 'tests/auto-register-eligible-pattern', $patterns ); + $this->assertSame( $pattern, $patterns['tests/auto-register-eligible-pattern'] ); + } + + /** + * @ticket 65628 + */ + public function test_excludes_ineligible_blocks_from_pattern_bootstrap() { + $this->register_test_block( + 'tests/auto-register-without-flag', + array( 'pattern' => self::PATTERN ) + ); + $this->register_test_block( + 'tests/auto-register-without-pattern', + array( 'supports' => array( 'autoRegister' => true ) ) + ); + $this->register_test_block( + 'tests/auto-register-empty-pattern', + array( + 'supports' => array( 'autoRegister' => true ), + 'pattern' => '', + ) + ); + $this->register_test_block( + 'tests/auto-register-non-string-pattern', + array( + 'supports' => array( 'autoRegister' => true ), + 'pattern' => array( 'not markup' ), + ) + ); + + _wp_enqueue_auto_register_blocks(); + + $this->assertNull( $this->get_auto_register_bootstrap_data( '__unstableAutoRegisterBlockPatterns' ) ); + } + + /** + * @ticket 65628 + */ + public function test_preserves_legacy_callback_bootstrap_without_adding_a_pattern_script() { + $this->register_test_block( + 'tests/auto-register-callback', + array( + 'supports' => array( 'autoRegister' => true ), + 'render_callback' => static function () { + return '

Callback output.

'; + }, + ) + ); + + _wp_enqueue_auto_register_blocks(); + + $auto_register_blocks = $this->get_auto_register_bootstrap_data( '__unstableAutoRegisterBlocks' ); + $this->assertIsArray( $auto_register_blocks ); + $this->assertContains( 'tests/auto-register-callback', $auto_register_blocks ); + $this->assertNull( $this->get_auto_register_bootstrap_data( '__unstableAutoRegisterBlockPatterns' ) ); + } }