Skip to content
Merged
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
4 changes: 2 additions & 2 deletions wordpress-plugin/gk-block-api/gk-block-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Plugin Name: GK Block API
* Plugin URI: https://www.gravitykit.com
* Description: REST API for block-level CRUD operations with smart preferences for AI agents.
* Version: 1.8.0
* Version: 1.8.1
* Author: GravityKit
* Author URI: https://www.gravitykit.com
* License: GPL-2.0-or-later
Expand All @@ -23,7 +23,7 @@
exit;
}

define( 'GK_BLOCK_API_VERSION', '1.8.0' );
define( 'GK_BLOCK_API_VERSION', '1.8.1' );
define( 'GK_BLOCK_API_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'GK_BLOCK_API_PLUGIN_URL', plugin_dir_url( __FILE__ ) );

Expand Down
36 changes: 36 additions & 0 deletions wordpress-plugin/gk-block-api/includes/class-block-mutator.php
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,42 @@ function ( $piece ) use ( $transformer, $block_type_name, $attributes ) {
// Insert a null placeholder for the new child in innerContent.
$ic = &$parent[ $target_index ]['innerContent'];

// Normalise self-closing wrappers before splicing. A container
// created by insert_blocks with innerHTML="<div…></div>" and
// no innerBlocks parses back with innerContent stored as a
// single, unsplit string. Without a separable opening/closing
// pair, the splice logic below lands the new null adjacent to
// that string and serialize_blocks() emits children OUTSIDE
// the wrapper. Splitting the wrapper at the first `>` here
// turns ['<div></div>'] into ['<div>', '</div>'] so the new
// null falls between them, preserving the contract that
// children are interleaved INSIDE the wrapper.
$has_null = false;
foreach ( $ic as $piece ) {
if ( null === $piece ) {
$has_null = true;
break;
}
}
if ( ! $has_null ) {
foreach ( $ic as $piece_idx => $piece ) {
if ( ! is_string( $piece ) ) {
continue;
}
$open_end = strpos( $piece, '>' );
if ( false === $open_end || ( strlen( $piece ) - 1 ) === $open_end ) {
continue;
}
$opening = substr( $piece, 0, $open_end + 1 );
$closing = substr( $piece, $open_end + 1 );
if ( '' === $closing ) {
continue;
}
array_splice( $ic, $piece_idx, 1, array( $opening, $closing ) );
break;
}
}

if ( 'start' === $position ) {
// Insert after the first string entry (opening tag).
$insert_at = 0;
Expand Down
13 changes: 12 additions & 1 deletion wordpress-plugin/gk-block-api/readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Tags: blocks, rest-api, gutenberg, mcp, ai
Requires at least: 6.0
Tested up to: 6.9
Requires PHP: 7.4
Stable tag: 1.8.0
Stable tag: 1.8.1
License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Expand Down Expand Up @@ -96,6 +96,9 @@ Visit Settings → Block MCP. Set the score for a namespace to less than 10 to m

== Upgrade Notice ==

= 1.8.1 =
Fixes nested container blocks (columns, columns/column, group, buttons) rendering their children as top-level siblings on the front end while looking correct in the editor. Inside-out page builds through `insert_blocks` + `edit_block_tree.insert-child` now serialise with children correctly nested inside their wrappers.

= 1.8.0 =
Every WordPress core block and the full Gutenberg trunk block library now compose cleanly through the write API. Each insert is validated against the block's inline HTML attribute definitions, so malformed input is caught up front with a clear, actionable error before becoming an "invalid content" warning in the editor.

Expand Down Expand Up @@ -134,6 +137,14 @@ Docs lifecycle tools (`create_post`, `update_post`, `list_terms`, `upload_media`

== Changelog ==

= 1.8.1 on May 22, 2026 =

Fixes nested container blocks rendering their children as top-level siblings on the front end while looking correct in the editor. Pages built inside-out — adding columns first, then columns/column children, then content — now render with children correctly nested inside their wrappers.

#### 🐛 Fixed

* Nested layouts built via `insert-child` (columns/column, group, buttons, hero splits, and similar containers) now serialise with children inside their wrapper instead of as siblings outside it. The editor was always correct; only the front-end output was broken.

= 1.8.0 on May 21, 2026 =

Every WordPress core block and the full Gutenberg trunk block library now compose cleanly through the write API. Each insert is validated against the block's inline HTML attribute definitions, so malformed input is caught up front with a clear, actionable error before becoming an "invalid content" warning in the editor.
Expand Down
156 changes: 156 additions & 0 deletions wordpress-plugin/gk-block-api/tests/Block/BlockMutatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,162 @@ public function test_insert_child_rejects_nested_legacy_block() {
$this->assertInstanceOf( \WP_Error::class, $result );
}

/**
* insert-child must nest the new block INSIDE the wrapper, not as a sibling.
*
* Regression pins BLOCK-20: when a container is created with a self-closing
* wrapper and no children (e.g. insert_blocks called with
* innerHTML="<div class=\"wp-block-columns\"></div>" and an empty
* innerBlocks list), parse_blocks reads innerContent back as a single
* unsplit string ['<div class="wp-block-columns"></div>']. The original
* insert-child logic scanned backward for the "closing-tag string" and
* spliced the null at that string's index — but with only one string the
* splice landed the null BEFORE it, producing [null, '<div></div>'] and a
* serialized output of `<child-block><div></div>` (child outside the
* wrapper, wrapper rendered empty AFTER children). The fix normalises
* such single-string wrappers into [opening, closing] before splicing so
* the new null lands between them.
*/
public function test_insert_child_nests_inside_self_closing_wrapper() {
// Seed the post with the exact innerContent shape parse_blocks
// produces for an empty container wrapper inserted with no children.
$this->make_post( array(
array(
'blockName' => 'core/columns',
'attrs' => array(),
'innerHTML' => '<div class="wp-block-columns"></div>',
'innerContent' => array( '<div class="wp-block-columns"></div>' ),
'innerBlocks' => array(),
),
) );

$result = $this->mutator->mutate(
$this->post_id,
'insert-child',
array( 0 ),
array(
'block' => array(
'name' => 'core/column',
'innerHTML' => '<div class="wp-block-column"></div>',
),
)
);

$this->assertTrue( $result['success'] );

$saved = $this->current_blocks();
$this->assertCount( 1, $saved[0]['innerBlocks'], 'columns must contain the inserted column' );

// innerContent must have a string-null-string shape so serialize_blocks
// interleaves the child inside the wrapper.
$ic = $saved[0]['innerContent'];
$this->assertGreaterThanOrEqual( 3, count( $ic ), 'innerContent must be split open/null/close after insert.' );
$this->assertIsString( $ic[0], 'First innerContent entry must be the opening wrapper string.' );
$null_indexes = array_keys( array_filter( $ic, function ( $piece ) { return null === $piece; } ) );
$this->assertCount( 1, $null_indexes, 'Exactly one null placeholder for the inserted child.' );
$null_at = $null_indexes[0];
$this->assertGreaterThan( 0, $null_at, 'Null must come AFTER the opening wrapper string.' );
$this->assertLessThan( count( $ic ) - 1, $null_at, 'Null must come BEFORE the closing wrapper string.' );

// The serialized markup must place the child block comments between
// the wrapper's opening and closing tags, not outside them.
$serialized = serialize_blocks( $saved );
$open_at = strpos( $serialized, '<div class="wp-block-columns">' );
$close_at = strpos( $serialized, '</div>', $open_at );
$child_at = strpos( $serialized, 'wp:column ' );
$this->assertNotFalse( $open_at, 'Opening columns tag must be present.' );
$this->assertNotFalse( $close_at, 'Closing columns tag must be present.' );
$this->assertNotFalse( $child_at, 'Child column comment must be present.' );
$this->assertGreaterThan( $open_at, $child_at, 'Child must appear AFTER the opening wrapper tag.' );
$this->assertLessThan( $close_at, $child_at, 'Child must appear BEFORE the closing wrapper tag.' );
}

/**
* insert-child handles the full inside-out chain: columns → column → heading.
*
* Regression pins the multi-level BLOCK-20 repro: a page built by
* insert_blocks (self-closing columns wrapper) followed by two cascading
* insert-child calls (a column inside the columns, then a heading inside
* that column) used to serialise as three empty wrappers with the heading
* as a top-level sibling. After the fix, each intermediate wrapper must
* be normalised on demand so the heading lands two levels deep, between
* the column's opening and closing tags, which in turn sit between the
* columns' opening and closing tags.
*/
public function test_insert_child_nests_through_multiple_self_closing_wrappers() {
// Step 1 equivalent — seed with what insert_blocks(columns, no children)
// leaves behind after a parse_blocks round-trip.
$this->make_post( array(
array(
'blockName' => 'core/columns',
'attrs' => array(),
'innerHTML' => '<div class="wp-block-columns"></div>',
'innerContent' => array( '<div class="wp-block-columns"></div>' ),
'innerBlocks' => array(),
),
) );

// Step 2: insert-child a self-closing column into the columns wrapper.
$add_column = $this->mutator->mutate(
$this->post_id,
'insert-child',
array( 0 ),
array(
'block' => array(
'name' => 'core/column',
'innerHTML' => '<div class="wp-block-column"></div>',
),
)
);
$this->assertTrue( $add_column['success'] );

// Step 3: insert-child a heading into that column (path [0, 0]).
$add_heading = $this->mutator->mutate(
$this->post_id,
'insert-child',
array( 0, 0 ),
array(
'block' => array(
'name' => 'core/heading',
'attributes' => array( 'level' => 3 ),
'innerHTML' => '<h3 class="wp-block-heading">Hello</h3>',
),
)
);
$this->assertTrue( $add_heading['success'] );

$saved = $this->current_blocks();
$this->assertCount( 1, $saved[0]['innerBlocks'], 'columns has one column' );
$this->assertCount( 1, $saved[0]['innerBlocks'][0]['innerBlocks'], 'column has one heading' );
$this->assertSame( 'core/heading', $saved[0]['innerBlocks'][0]['innerBlocks'][0]['blockName'] );

// Both wrappers must now have a null sandwiched between opening/closing
// strings so serialize_blocks() interleaves children inside them.
$columns_ic = $saved[0]['innerContent'];
$column_ic = $saved[0]['innerBlocks'][0]['innerContent'];
$this->assertGreaterThanOrEqual( 3, count( $columns_ic ), 'columns innerContent must be split.' );
$this->assertGreaterThanOrEqual( 3, count( $column_ic ), 'column innerContent must be split.' );

// End-to-end serialisation check: the heading must sit two levels deep,
// between the column's opening and closing tags (which sit between the
// columns' opening and closing tags).
$serialized = serialize_blocks( $saved );
$cols_open = strpos( $serialized, '<div class="wp-block-columns">' );
$col_open = strpos( $serialized, '<div class="wp-block-column">' );
$heading_at = strpos( $serialized, 'wp:heading' );
$col_close = strpos( $serialized, '</div>', $col_open );
$cols_close = $col_close !== false ? strpos( $serialized, '</div>', $col_close + 6 ) : false;
$this->assertNotFalse( $cols_open, 'Opening columns tag must be present.' );
$this->assertNotFalse( $col_open, 'Opening column tag must be present.' );
$this->assertNotFalse( $heading_at, 'Heading block comment must be present.' );
$this->assertNotFalse( $col_close, 'Closing column tag must be present.' );
$this->assertNotFalse( $cols_close, 'Closing columns tag must be present.' );
$this->assertGreaterThan( $cols_open, $col_open, 'column must appear AFTER columns opening.' );
$this->assertGreaterThan( $col_open, $heading_at, 'heading must appear AFTER column opening.' );
$this->assertLessThan( $col_close, $heading_at, 'heading must appear BEFORE column closing.' );
$this->assertLessThan( $cols_close, $col_close, 'column close must appear BEFORE columns close.' );
}

// ── emoji round-trip ───────────────────────────────────────────

/**
Expand Down
Loading