From 75768e71cfdb2cda8fce22b9fe03016625692f6c Mon Sep 17 00:00:00 2001 From: Saksham Sharma Date: Wed, 21 Oct 2026 17:17:41 +0530 Subject: [PATCH] Fix: preserve parent presets when saving child theme changes --- includes/create-theme/resolver_additions.php | 123 +++++++++++++++++++ tests/test-resolver-preset-merge.php | 120 ++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 tests/test-resolver-preset-merge.php diff --git a/includes/create-theme/resolver_additions.php b/includes/create-theme/resolver_additions.php index 3de2cc25..6800c1a1 100644 --- a/includes/create-theme/resolver_additions.php +++ b/includes/create-theme/resolver_additions.php @@ -79,6 +79,16 @@ public static function export_theme_data( $content, $extra_theme_data = null ) { $data = $theme->get_data(); + if ( 'current' === $content && $current_theme->parent() ) { + $parent_file = static::get_file_path_from_theme( 'theme.json', true ); + if ( $parent_file ) { + $parent_data = static::read_json_file( $parent_file ); + if ( is_array( $parent_data ) ) { + $data = static::merge_parent_preset_arrays( $data, $parent_data ); + } + } + } + // Add the schema. if ( empty( $schema ) ) { global $wp_version; @@ -102,6 +112,119 @@ public static function export_theme_data( $content, $extra_theme_data = null ) { return static::stringify( $data ); } + /** + * Merge parent theme preset arrays into child theme data. + * + * For child themes, WordPress Core replaces indexed preset arrays (like color palettes, + * font sizes, gradients) entirely rather than merging them. This method traverses + * child data and backfills parent preset entries for any preset array present in child data, + * with child entries overriding parent entries that share the same slug. + * + * @param array $child_data Child theme data. + * @param array $parent_data Parent theme data. + * @return array Merged theme data. + */ + public static function merge_parent_preset_arrays( array $child_data, array $parent_data ) { + if ( empty( $child_data ) || empty( $parent_data ) ) { + return $child_data; + } + + $result = $child_data; + + foreach ( $child_data as $key => $child_value ) { + // Non-arrays (scalars, booleans, null) are already preserved in $result. + if ( ! is_array( $child_value ) ) { + continue; + } + + // Associative objects (settings, color, blocks, etc.): recurse. + if ( ! static::is_list( $child_value ) ) { + $parent_subdata = ( isset( $parent_data[ $key ] ) && is_array( $parent_data[ $key ] ) ) ? $parent_data[ $key ] : array(); + $result[ $key ] = static::merge_parent_preset_arrays( $child_value, $parent_subdata ); + continue; + } + + // Slug-keyed preset lists: backfill parent entries if parent defines this preset. + if ( static::is_preset_list( $child_value ) && isset( $parent_data[ $key ] ) && static::is_preset_list( $parent_data[ $key ] ) ) { + $result[ $key ] = static::merge_preset_list( $child_value, $parent_data[ $key ] ); + } + } + + return $result; + } + + /** + * Check whether a value is a slug-keyed preset list (e.g. palette, fontSizes). + * + * @param mixed $value Value to check. + * @return bool Whether the value is a preset list. + */ + private static function is_preset_list( $value ) { + return is_array( $value ) + && static::is_list( $value ) + && ! empty( $value ) + && is_array( $value[0] ) + && isset( $value[0]['slug'] ); + } + + /** + * Merge parent and child preset lists keyed by slug. + * + * Preserves parent item order with child overrides replacing parent items + * at their original positions, and new child entries appended. + * + * @param array $child_list Child preset list. + * @param array $parent_list Parent preset list. + * @return array Merged preset list. + */ + private static function merge_preset_list( array $child_list, array $parent_list ) { + $merged = array(); + $slug_to_index = array(); + + foreach ( $parent_list as $item ) { + if ( is_array( $item ) && isset( $item['slug'] ) ) { + $slug_to_index[ $item['slug'] ] = count( $merged ); + } + $merged[] = $item; + } + + foreach ( $child_list as $item ) { + if ( is_array( $item ) && isset( $item['slug'] ) && isset( $slug_to_index[ $item['slug'] ] ) ) { + $merged[ $slug_to_index[ $item['slug'] ] ] = $item; + } else { + if ( is_array( $item ) && isset( $item['slug'] ) ) { + $slug_to_index[ $item['slug'] ] = count( $merged ); + } + $merged[] = $item; + } + } + + return $merged; + } + + /** + * Detect whether an array is a list (sequential integer keys starting at 0). + * Mirrors PHP 8.1+ `array_is_list()`. + * + * @param array $arr Array to check. + * @return bool Whether the array is a list. + */ + private static function is_list( array $arr ) { + if ( function_exists( 'array_is_list' ) ) { + return array_is_list( $arr ); + } + if ( array() === $arr ) { + return true; + } + $expected = 0; + foreach ( $arr as $key => $_v ) { + if ( $key !== $expected++ ) { + return false; + } + } + return true; + } + /** * Get the user data. * diff --git a/tests/test-resolver-preset-merge.php b/tests/test-resolver-preset-merge.php new file mode 100644 index 00000000..414613e6 --- /dev/null +++ b/tests/test-resolver-preset-merge.php @@ -0,0 +1,120 @@ + array( + 'color' => array( + 'palette' => array( + array( + 'slug' => 'base', + 'color' => '#ffffff', + 'name' => 'Base', + ), + array( + 'slug' => 'contrast', + 'color' => '#000000', + 'name' => 'Contrast', + ), + ), + ), + ), + ); + + $child = array( + 'settings' => array( + 'color' => array( + 'palette' => array( + array( + 'slug' => 'base', + 'color' => '#f0f0f0', + 'name' => 'Custom Base', + ), + array( + 'slug' => 'accent', + 'color' => '#0073aa', + 'name' => 'Accent', + ), + ), + ), + ), + ); + + $result = CBT_Theme_JSON_Resolver::merge_parent_preset_arrays( $child, $parent ); + $palette = $result['settings']['color']['palette']; + + $this->assertCount( 3, $palette ); + $this->assertSame( array( 'base', 'contrast', 'accent' ), array_column( $palette, 'slug' ) ); + $this->assertSame( '#f0f0f0', $palette[0]['color'] ); + $this->assertSame( 'Custom Base', $palette[0]['name'] ); + $this->assertSame( '#000000', $palette[1]['color'] ); + $this->assertSame( '#0073aa', $palette[2]['color'] ); + } + + public function test_merge_empty_child_preset_clears() { + $parent = array( + 'settings' => array( + 'color' => array( + 'palette' => array( + array( + 'slug' => 'base', + 'color' => '#ffffff', + 'name' => 'Base', + ), + ), + ), + ), + ); + + $child = array( + 'settings' => array( + 'color' => array( + 'palette' => array(), + ), + ), + ); + + $result = CBT_Theme_JSON_Resolver::merge_parent_preset_arrays( $child, $parent ); + $this->assertSame( array(), $result['settings']['color']['palette'] ); + } + + public function test_merge_preserves_non_preset_deltas() { + $parent = array( + 'settings' => array( + 'layout' => array( + 'contentSize' => '645px', + 'wideSize' => '1200px', + ), + 'spacing' => array( + 'units' => array( '%', 'px', 'em' ), + ), + ), + ); + + $child = array( + 'settings' => array( + 'layout' => array( + 'contentSize' => '800px', + ), + 'spacing' => array( + 'units' => array( '%', 'rem' ), + ), + ), + ); + + $result = CBT_Theme_JSON_Resolver::merge_parent_preset_arrays( $child, $parent ); + + $this->assertSame( array( 'contentSize' => '800px' ), $result['settings']['layout'] ); + $this->assertSame( array( '%', 'rem' ), $result['settings']['spacing']['units'] ); + } +}