diff --git a/wordpress-plugin/gk-block-api/gk-block-api.php b/wordpress-plugin/gk-block-api/gk-block-api.php index f01ca70..ccfdd99 100644 --- a/wordpress-plugin/gk-block-api/gk-block-api.php +++ b/wordpress-plugin/gk-block-api/gk-block-api.php @@ -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 @@ -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__ ) ); diff --git a/wordpress-plugin/gk-block-api/includes/class-block-mutator.php b/wordpress-plugin/gk-block-api/includes/class-block-mutator.php index d3ac22c..a9cd05e 100644 --- a/wordpress-plugin/gk-block-api/includes/class-block-mutator.php +++ b/wordpress-plugin/gk-block-api/includes/class-block-mutator.php @@ -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="" 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 ['
'] into ['
', '
'] 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; diff --git a/wordpress-plugin/gk-block-api/readme.txt b/wordpress-plugin/gk-block-api/readme.txt index be7687b..8ef0f60 100644 --- a/wordpress-plugin/gk-block-api/readme.txt +++ b/wordpress-plugin/gk-block-api/readme.txt @@ -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 @@ -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. @@ -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. diff --git a/wordpress-plugin/gk-block-api/tests/Block/BlockMutatorTest.php b/wordpress-plugin/gk-block-api/tests/Block/BlockMutatorTest.php index 0c3a9e7..16d97dc 100644 --- a/wordpress-plugin/gk-block-api/tests/Block/BlockMutatorTest.php +++ b/wordpress-plugin/gk-block-api/tests/Block/BlockMutatorTest.php @@ -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="
" and an empty + * innerBlocks list), parse_blocks reads innerContent back as a single + * unsplit string ['
']. 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, '
'] and a + * serialized output of `
` (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' => '
', + 'innerContent' => array( '
' ), + 'innerBlocks' => array(), + ), + ) ); + + $result = $this->mutator->mutate( + $this->post_id, + 'insert-child', + array( 0 ), + array( + 'block' => array( + 'name' => 'core/column', + 'innerHTML' => '
', + ), + ) + ); + + $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, '
' ); + $close_at = strpos( $serialized, '
', $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' => '
', + 'innerContent' => array( '
' ), + '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' => '
', + ), + ) + ); + $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' => '

Hello

', + ), + ) + ); + $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, '
' ); + $col_open = strpos( $serialized, '
' ); + $heading_at = strpos( $serialized, 'wp:heading' ); + $col_close = strpos( $serialized, '
', $col_open ); + $cols_close = $col_close !== false ? strpos( $serialized, '
', $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 ─────────────────────────────────────────── /**