Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backport-changelog/7.1/12113.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
https://github.com/WordPress/wordpress-develop/pull/12113

* https://github.com/WordPress/gutenberg/pull/78991
82 changes: 81 additions & 1 deletion lib/compat/wordpress-7.1/block-bindings.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php
/**
* Code block bindings additions for WordPress 7.1.
* Block bindings additions for WordPress 7.1.
*
* @since 7.1.0
* @package gutenberg
Expand All @@ -19,3 +19,83 @@ function ( $attributes, $block_type ) {
10,
2
);

/*
* On WordPress versions before 7.1, `WP_Block::replace_html()` lacks the
* inner-blocks fix (wordpress-develop#12113), so binding a List Item's content
* replaces the whole `<li>` and drops the nested List rendered inside it. Core
* still has the inner blocks on the instance, so re-append them after the
* binding has been applied.
*
* This can be removed once the minimum required WordPress version is 7.1.
*/

/**
* Returns whether the list item metadata declares a content binding.
*
* @since 7.1.0
*
* @param mixed $metadata Block metadata attribute.
* @return bool Whether a content binding (direct or via pattern overrides) is present.
*/
function gutenberg_list_item_metadata_has_content_binding( $metadata ) {
if ( empty( $metadata['bindings'] ) || ! is_array( $metadata['bindings'] ) ) {
return false;
}

$bindings = $metadata['bindings'];
return isset( $bindings['content'] ) ||
(
isset( $bindings['__default']['source'] ) &&
'core/pattern-overrides' === $bindings['__default']['source']
);
}

/**
* Restores the nested inner blocks dropped when binding a List Item's content.
*
* Core replaces the whole `<li>` when it applies the rich-text binding, removing
* the nested List. The inner blocks are still available on the instance, so they
* are rendered again and re-appended before the closing `</li>`.
*
* @since 7.1.0
*
* @param string $block_content The rendered block content.
* @param array $parsed_block The parsed block.
* @param WP_Block $instance The block instance.
* @return string The block content with the nested inner blocks restored.
*/
function gutenberg_restore_list_item_inner_blocks_after_binding( $block_content, $parsed_block, $instance ) {
if ( 'core/list-item' !== ( $parsed_block['blockName'] ?? null ) ) {
return $block_content;
}

if ( ! $instance instanceof WP_Block || empty( $instance->inner_blocks ) ) {
return $block_content;
}

if ( ! gutenberg_list_item_metadata_has_content_binding( $instance->parsed_block['attrs']['metadata'] ?? null ) ) {
return $block_content;
}

$inner_blocks_html = '';
foreach ( $instance->inner_blocks as $inner_block ) {
$inner_blocks_html .= $inner_block->render();
}

/*
* Nothing to restore when the inner blocks did not render, or when they are
* still present because Core already preserves them (the fix is in place).
*/
if ( '' === $inner_blocks_html || str_contains( $block_content, $inner_blocks_html ) ) {
return $block_content;
}

$closer_position = strripos( $block_content, '</li>' );
if ( false === $closer_position ) {
return $block_content;
}

return substr( $block_content, 0, $closer_position ) . $inner_blocks_html . substr( $block_content, $closer_position );
}
add_filter( 'render_block', 'gutenberg_restore_list_item_inner_blocks_after_binding', 10, 3 );
271 changes: 271 additions & 0 deletions phpunit/block-bindings-test.php
Original file line number Diff line number Diff line change
Expand Up @@ -539,4 +539,275 @@ public function test_update_list_item_original_inline_image_is_replaced_by_sourc
'The original inline image alt text should not remain after binding replacement.'
);
}

/**
* Tests if list item content updates preserve nested list inner blocks.
*
* @covers WP_Block::render
*/
public function test_update_list_item_with_nested_list_preserves_nested_list() {
$block_content = <<<HTML
<!-- wp:list -->
<ul class="wp-block-list"><!-- wp:list-item {"metadata":{"bindings":{"content":{"source":"test/source"}}}} -->
<li>Default content<!-- wp:list -->
<ul class="wp-block-list"><!-- wp:list-item -->
<li>Nested child</li>
<!-- /wp:list-item --></ul>
<!-- /wp:list --></li>
<!-- /wp:list-item --></ul>
<!-- /wp:list -->
HTML;
$result = $this->render_list_item_with_source_value( 'Bound list item', $block_content );
$normalized = preg_replace( '/>\s+</', '><', trim( $result ) );

$this->assertStringNotContainsString(
'Default content',
$result,
'The original list item content should be replaced by the source value.'
);
$this->assertMatchesRegularExpression(
'#<li>Bound list item\s*<ul class="wp-block-list"><li>Nested child</li></ul></li>#',
$normalized,
'The list item should render the source text and preserve nested list inner blocks.'
);
}

/**
* Tests if raw list markup before a nested list inner block is replaced with the original content.
*
* @covers WP_Block::render
*/
public function test_update_list_item_with_raw_list_markup_before_nested_list_replaces_raw_markup() {
$block_content = <<<HTML
<!-- wp:list -->
<ul class="wp-block-list"><!-- wp:list-item {"metadata":{"bindings":{"content":{"source":"test/source"}}}} -->
<li>Default content<ul><li>Raw list markup should be replaced</li></ul><!-- wp:list -->
<ul class="wp-block-list"><!-- wp:list-item -->
<li>Nested child should remain</li>
<!-- /wp:list-item --></ul>
<!-- /wp:list --></li>
<!-- /wp:list-item --></ul>
<!-- /wp:list -->
HTML;
$result = $this->render_list_item_with_source_value( 'Bound list item', $block_content );
$normalized = preg_replace( '/>\s+</', '><', trim( $result ) );

$this->assertStringNotContainsString(
'Raw list markup should be replaced',
$result,
'Raw list markup before the nested block should be replaced by the source value.'
);
$this->assertMatchesRegularExpression(
'#<li>Bound list item\s*<ul class="wp-block-list"><li>Nested child should remain</li></ul></li>#',
$normalized,
'The list item should preserve only the delimiter-backed nested list inner block.'
);
}

/**
* Tests if an empty source value clears the content but preserves nested list inner blocks.
*
* @covers WP_Block::render
*/
public function test_update_list_item_with_empty_value_preserves_nested_list() {
$block_content = <<<HTML
<!-- wp:list -->
<ul class="wp-block-list"><!-- wp:list-item {"metadata":{"bindings":{"content":{"source":"test/source"}}}} -->
<li>Default content<!-- wp:list -->
<ul class="wp-block-list"><!-- wp:list-item -->
<li>Nested child</li>
<!-- /wp:list-item --></ul>
<!-- /wp:list --></li>
<!-- /wp:list-item --></ul>
<!-- /wp:list -->
HTML;
$result = $this->render_list_item_with_source_value( '', $block_content );
$normalized = preg_replace( '/>\s+</', '><', trim( $result ) );

$this->assertStringNotContainsString(
'Default content',
$result,
'An empty source value should clear the original list item content.'
);
$this->assertMatchesRegularExpression(
'#<li>\s*<ul class="wp-block-list"><li>Nested child</li></ul></li>#',
$normalized,
'An empty source value should still preserve nested list inner blocks.'
);
}

/**
* Tests if content updates preserve a nested ordered list inner block.
*
* @covers WP_Block::render
*/
public function test_update_list_item_with_nested_ordered_list_preserves_nested_list() {
$block_content = <<<HTML
<!-- wp:list -->
<ul class="wp-block-list"><!-- wp:list-item {"metadata":{"bindings":{"content":{"source":"test/source"}}}} -->
<li>Default content<!-- wp:list {"ordered":true} -->
<ol class="wp-block-list"><!-- wp:list-item -->
<li>Nested ordered child</li>
<!-- /wp:list-item --></ol>
<!-- /wp:list --></li>
<!-- /wp:list-item --></ul>
<!-- /wp:list -->
HTML;
$result = $this->render_list_item_with_source_value( 'Bound list item', $block_content );
$normalized = preg_replace( '/>\s+</', '><', trim( $result ) );

$this->assertStringNotContainsString(
'Default content',
$result,
'The original list item content should be replaced by the source value.'
);
$this->assertMatchesRegularExpression(
'#<li>Bound list item\s*<ol class="wp-block-list"><li>Nested ordered child</li></ol></li>#',
$normalized,
'The list item should render the source text and preserve the nested ordered list.'
);
}

/**
* Tests if content updates preserve multiple levels of nested list inner blocks.
*
* @covers WP_Block::render
*/
public function test_update_list_item_with_deeply_nested_list_preserves_all_levels() {
$block_content = <<<HTML
<!-- wp:list -->
<ul class="wp-block-list"><!-- wp:list-item {"metadata":{"bindings":{"content":{"source":"test/source"}}}} -->
<li>Level one default<!-- wp:list -->
<ul class="wp-block-list"><!-- wp:list-item -->
<li>Level two<!-- wp:list -->
<ul class="wp-block-list"><!-- wp:list-item -->
<li>Level three</li>
<!-- /wp:list-item --></ul>
<!-- /wp:list --></li>
<!-- /wp:list-item --></ul>
<!-- /wp:list --></li>
<!-- /wp:list-item --></ul>
<!-- /wp:list -->
HTML;
$result = $this->render_list_item_with_source_value( 'Bound list item', $block_content );

$this->assertStringNotContainsString(
'Level one default',
$result,
'Only the bound list item content should be replaced.'
);
$this->assertStringContainsString(
'Level two',
$result,
'The first nested level should be preserved.'
);
$this->assertStringContainsString(
'Level three',
$result,
'The second nested level should be preserved.'
);
}

/**
* Tests that a non-list inner block is preserved when the content is bound.
*
* The list item block only permits `core/list` children through the editor,
* but block markup can be authored or pasted with other inner blocks. The
* render path detects the first inner block by structure (not by tag name),
* so any block type before which the rich text ends must be preserved.
*
* @covers WP_Block::render
*/
public function test_update_list_item_preserves_non_list_inner_block() {
$block_content = <<<HTML
<!-- wp:list -->
<ul class="wp-block-list"><!-- wp:list-item {"metadata":{"bindings":{"content":{"source":"test/source"}}}} -->
<li>Default content<!-- wp:button -->
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button">Keep this button</a></div>
<!-- /wp:button --></li>
<!-- /wp:list-item --></ul>
<!-- /wp:list -->
HTML;
$result = $this->render_list_item_with_source_value( 'Bound list item', $block_content );

$this->assertStringNotContainsString(
'Default content',
$result,
'The original list item content should be replaced by the source value.'
);
$this->assertStringContainsString(
'Bound list item',
$result,
'The source value should be rendered as the list item content.'
);
$this->assertStringContainsString(
'wp-block-button',
$result,
'The nested button inner block should be preserved.'
);
$this->assertStringContainsString(
'Keep this button',
$result,
'The nested button inner block content should be preserved.'
);
}

/**
* Tests if pattern overrides update list item content without removing nested lists.
*
* @covers WP_Block::process_block_bindings
*/
public function test_default_binding_for_list_item_pattern_overrides() {
$list_item_name = 'Editable List Item';
$block_content = <<<HTML
<!-- wp:list -->
<ul class="wp-block-list"><!-- wp:list-item {"metadata":{"bindings":{"__default":{"source":"core/pattern-overrides"}},"name":"$list_item_name"}} -->
<li>Default content<!-- wp:list -->
<ul class="wp-block-list"><!-- wp:list-item -->
<li>Nested child</li>
<!-- /wp:list-item --></ul>
<!-- /wp:list --></li>
<!-- /wp:list-item --></ul>
<!-- /wp:list -->
HTML;

$expected_content = 'Pattern <em>override</em>';
$parsed_blocks = parse_blocks( $block_content );
$block = new WP_Block(
$parsed_blocks[0],
array(
'pattern/overrides' => array(
$list_item_name => array( 'content' => $expected_content ),
),
)
);

$result = $block->render();
$normalized = preg_replace( '/>\s+</', '><', trim( $result ) );

$this->assertStringNotContainsString(
'Default content',
$result,
'The original list item content should be replaced by the pattern override.'
);
$this->assertStringContainsString(
'<li>Nested child</li>',
$normalized,
'Nested list inner blocks should remain in the rendered output.'
);
$this->assertMatchesRegularExpression(
'#<li>Pattern <em>override</em><ul class="wp-block-list"><li>Nested child</li></ul></li>#',
$normalized,
'The list item should render the pattern override before its nested list.'
);

$expected_bindings_metadata = array(
'content' => array( 'source' => 'core/pattern-overrides' ),
);
$this->assertSame(
$expected_bindings_metadata,
$block->inner_blocks[0]->attributes['metadata']['bindings'],
'The __default binding should be updated with the list item content binding metadata.'
);
}
}
Loading
Loading