diff --git a/includes/class-create-block-theme-api.php b/includes/class-create-block-theme-api.php index 12c42522..b988452d 100644 --- a/includes/class-create-block-theme-api.php +++ b/includes/class-create-block-theme-api.php @@ -13,6 +13,7 @@ require_once __DIR__ . '/create-theme/theme-readme.php'; require_once __DIR__ . '/create-theme/theme-fonts.php'; require_once __DIR__ . '/create-theme/theme-create.php'; +require_once __DIR__ . '/create-theme/theme-settings-save.php'; /** * The api functionality of the plugin leveraged by the site editor UI. @@ -68,6 +69,17 @@ public function register_rest_routes() { }, ) ); + register_rest_route( + 'create-block-theme/v1', + '/theme-settings', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'rest_save_theme_settings' ), + 'permission_callback' => function () { + return current_user_can( 'edit_theme_options' ); + }, + ) + ); register_rest_route( 'create-block-theme/v1', '/clone', @@ -394,6 +406,28 @@ function rest_save_theme( $request ) { ); } + /** + * Persist a partial theme.json payload from the Edit Theme Settings modal. + * + * Accepts the keys `settings`, `customTemplates`, `templateParts`, and + * `removedShadowDefaults`. Only keys present in the payload are written; + * missing keys leave the existing theme.json untouched. + */ + function rest_save_theme_settings( $request ) { + $result = CBT_Theme_Settings_Save::run( $request->get_json_params() ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return new WP_REST_Response( + array( + 'status' => 'SUCCESS', + 'theme_json' => $result, + ) + ); + } + /** * Get a list of all the font families used in the theme. * diff --git a/includes/create-theme/resolver_additions.php b/includes/create-theme/resolver_additions.php index 21117cf9..ca60b29f 100644 --- a/includes/create-theme/resolver_additions.php +++ b/includes/create-theme/resolver_additions.php @@ -135,8 +135,37 @@ public static function get_theme_file_contents() { public static function write_theme_file_contents( $theme_json_data ) { $theme_json = wp_json_encode( $theme_json_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); - file_put_contents( static::get_file_path_from_theme( 'theme.json' ), $theme_json ); + $target = static::get_file_path_from_theme( 'theme.json' ); + + // Atomic write with a request-unique temp file: write to a + // per-request sibling temp file, then rename into place. A + // per-request name prevents two concurrent saves from clobbering + // each other's staging payload (a shared `theme.json.tmp` is unsafe + // — request A could rename request B's truncated contents, or one + // could unlink the other's temp file mid-write). Each request + // cleans up only its own temp file on failure. + $tmp = $target . '.' . uniqid( '', true ) . '.tmp'; + // Suppress warnings so a permission/disk error returns false cleanly + // rather than emitting a PHP warning that may be promoted to an + // exception. Callers must check the boolean return value. + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + $bytes = @file_put_contents( $tmp, $theme_json ); + if ( false === $bytes ) { + return false; + } + + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + if ( ! @rename( $tmp, $target ) ) { + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + @unlink( $tmp ); + return false; + } + static::clean_cached_data(); + // Bust the WP-level theme cache too — `clean_cached_data()` only + // clears the JSON resolver's caches, not `wp_get_theme()`. + wp_get_theme()->cache_delete(); + return true; } public static function write_user_settings( $user_settings ) { diff --git a/includes/create-theme/theme-settings-save.php b/includes/create-theme/theme-settings-save.php new file mode 100644 index 00000000..37a3c42a --- /dev/null +++ b/includes/create-theme/theme-settings-save.php @@ -0,0 +1,518 @@ + 503 ) + ); + } + if ( ! flock( $lock_handle, LOCK_EX ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose + fclose( $lock_handle ); + return new WP_Error( + 'cbt_lock_failed', + __( 'Could not acquire theme.json save lock.', 'create-block-theme' ), + array( 'status' => 503 ) + ); + } + + try { + $current = CBT_Theme_JSON_Resolver::get_theme_file_contents(); + if ( ! is_array( $current ) ) { + $current = array(); + } + + $merged = self::merge( $current, $sanitized ); + + if ( array_key_exists( 'removedShadowDefaults', $sanitized ) ) { + $merged = self::reify_shadow_removals( $merged, $sanitized['removedShadowDefaults'] ); + } + + $wrote = CBT_Theme_JSON_Resolver::write_theme_file_contents( $merged ); + } finally { + flock( $lock_handle, LOCK_UN ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose + fclose( $lock_handle ); + } + + if ( true !== $wrote ) { + return new WP_Error( + 'cbt_write_failed', + __( 'Failed to write theme.json. Check filesystem permissions on the active theme directory.', 'create-block-theme' ), + array( 'status' => 500 ) + ); + } + + return $merged; + } + + /** + * Validate payload shape. Rejects unknown top-level keys and shape + * mismatches. Returns the payload with the operational key extracted but + * otherwise unchanged on success. + * + * @param array $payload Raw payload from the request. + * @return array|WP_Error + */ + public static function validate( array $payload ) { + foreach ( $payload as $key => $value ) { + if ( ! in_array( $key, self::ALLOWED_TOP_LEVEL_KEYS, true ) ) { + return new WP_Error( + 'cbt_invalid_payload', + sprintf( + /* translators: %s: unknown payload key */ + __( 'Unknown top-level key: %s', 'create-block-theme' ), + $key + ), + array( 'status' => 400 ) + ); + } + } + + if ( isset( $payload['settings'] ) ) { + if ( ! is_array( $payload['settings'] ) ) { + return new WP_Error( + 'cbt_invalid_payload', + __( '"settings" must be an object.', 'create-block-theme' ), + array( 'status' => 400 ) + ); + } + // Reject JSON lists in an object position. Empty array is + // ambiguous in PHP (both `{}` and `[]` decode to `[]`) and is + // allowed — `merge()` handles it as a no-op. + if ( ! empty( $payload['settings'] ) && self::is_list( $payload['settings'] ) ) { + return new WP_Error( + 'cbt_invalid_payload', + __( '"settings" must be an object, not a list.', 'create-block-theme' ), + array( 'status' => 400 ) + ); + } + } + + foreach ( array( 'customTemplates', 'templateParts' ) as $list_key ) { + if ( ! isset( $payload[ $list_key ] ) ) { + continue; + } + if ( ! is_array( $payload[ $list_key ] ) ) { + return new WP_Error( + 'cbt_invalid_payload', + sprintf( + /* translators: %s: payload key */ + __( '"%s" must be an array.', 'create-block-theme' ), + $list_key + ), + array( 'status' => 400 ) + ); + } + // Require a JSON list, not a JSON object. Empty array is ambiguous + // in PHP and is allowed. + if ( ! empty( $payload[ $list_key ] ) && ! self::is_list( $payload[ $list_key ] ) ) { + return new WP_Error( + 'cbt_invalid_payload', + sprintf( + /* translators: %s: payload key */ + __( '"%s" must be a list, not an object.', 'create-block-theme' ), + $list_key + ), + array( 'status' => 400 ) + ); + } + foreach ( $payload[ $list_key ] as $entry ) { + if ( ! is_array( $entry ) ) { + return new WP_Error( + 'cbt_invalid_payload', + sprintf( + /* translators: %s: payload key */ + __( 'Entries of "%s" must be objects.', 'create-block-theme' ), + $list_key + ), + array( 'status' => 400 ) + ); + } + } + } + + if ( isset( $payload['removedShadowDefaults'] ) ) { + if ( ! is_array( $payload['removedShadowDefaults'] ) ) { + return new WP_Error( + 'cbt_invalid_payload', + __( '"removedShadowDefaults" must be an array of slugs.', 'create-block-theme' ), + array( 'status' => 400 ) + ); + } + if ( ! empty( $payload['removedShadowDefaults'] ) && ! self::is_list( $payload['removedShadowDefaults'] ) ) { + return new WP_Error( + 'cbt_invalid_payload', + __( '"removedShadowDefaults" must be a list, not an object.', 'create-block-theme' ), + array( 'status' => 400 ) + ); + } + foreach ( $payload['removedShadowDefaults'] as $slug ) { + if ( ! is_string( $slug ) ) { + return new WP_Error( + 'cbt_invalid_payload', + __( 'Entries of "removedShadowDefaults" must be strings.', 'create-block-theme' ), + array( 'status' => 400 ) + ); + } + if ( sanitize_key( $slug ) !== $slug || '' === $slug ) { + return new WP_Error( + 'cbt_invalid_payload', + sprintf( + /* translators: %s: invalid slug */ + __( 'Invalid shadow slug "%s". Slugs must be lowercase alphanumeric with dashes or underscores.', 'create-block-theme' ), + $slug + ), + array( 'status' => 400 ) + ); + } + } + } + + // Validate required keys + slug format on customTemplates and templateParts + // entries. `name` is required on both (used as the file-system slug, + // e.g. templates/.html). `area` is required on templateParts; + // `title` is required on customTemplates. + $entry_required_keys = array( + 'customTemplates' => array( 'name', 'title' ), + 'templateParts' => array( 'name', 'area' ), + ); + foreach ( $entry_required_keys as $list_key => $required_keys ) { + if ( ! isset( $payload[ $list_key ] ) ) { + continue; + } + foreach ( $payload[ $list_key ] as $entry ) { + foreach ( $required_keys as $required_key ) { + if ( ! isset( $entry[ $required_key ] ) || ! is_string( $entry[ $required_key ] ) || '' === $entry[ $required_key ] ) { + return new WP_Error( + 'cbt_invalid_payload', + sprintf( + /* translators: 1: list key, 2: required key */ + __( 'Entries of "%1$s" must have a non-empty string "%2$s" field.', 'create-block-theme' ), + $list_key, + $required_key + ), + array( 'status' => 400 ) + ); + } + } + // `name` must be a valid slug (used as filename). + if ( sanitize_key( $entry['name'] ) !== $entry['name'] ) { + return new WP_Error( + 'cbt_invalid_payload', + sprintf( + /* translators: 1: list key, 2: invalid name */ + __( 'Invalid "%1$s" entry name "%2$s". Names must be lowercase alphanumeric with dashes or underscores.', 'create-block-theme' ), + $list_key, + $entry['name'] + ), + array( 'status' => 400 ) + ); + } + } + } + + return $payload; + } + + /** + * Recursively sanitize the payload, applying a context-aware sanitizer per + * leaf based on the parent key. + * + * - `name`/`title`/`label`/`description` → `sanitize_text_field()` (HTML-stripped labels). + * - `slug`/`area` → `sanitize_key()` (already validated to be slug-safe; this is belt-and-braces). + * - Entries inside `postTypes` lists → `sanitize_key()`. + * - Everything else (CSS values like `shadow`, `color`, `gradient`, `fontFamily`) + * → `wp_kses_no_null()` (strip NULL bytes only; preserve whitespace, parens, commas). + * + * Booleans and numbers pass through unchanged. + * + * @param mixed $value Value to sanitize. + * @param string $parent_key The key under which `$value` lives (empty for the root). + * For list entries, the list's key name. + * @return mixed + */ + public static function sanitize( $value, $parent_key = '' ) { + if ( is_array( $value ) ) { + $out = array(); + foreach ( $value as $k => $v ) { + // Associative-key children inherit their own key as context. + // List-entry children inherit the list's key as context. + $child_context = is_string( $k ) ? $k : $parent_key; + $out[ $k ] = self::sanitize( $v, $child_context ); + } + return $out; + } + if ( is_string( $value ) ) { + if ( in_array( $parent_key, self::TEXT_FIELD_KEYS, true ) ) { + return sanitize_text_field( $value ); + } + if ( + in_array( $parent_key, self::SLUG_FIELD_KEYS, true ) || + in_array( $parent_key, self::SLUG_LIST_KEYS, true ) + ) { + return sanitize_key( $value ); + } + return wp_kses_no_null( $value ); + } + return $value; + } + + /** + * Deep-merge $payload into $current using JSON Merge Patch (RFC 7396) + * semantics, with one PHP-imposed accommodation. + * + * Rules: + * + * - **`null` deletes the key.** Sending `{"settings":{"color":{"custom":null}}}` + * removes `settings.color.custom` from the result. Deleting a missing + * key is a no-op. + * - **Empty object (`{}`) is a no-op.** Per RFC 7396, an empty object means + * "no change at this key." PHP cannot distinguish a JSON `{}` from a JSON + * `[]` after `json_decode(..., true)` (both become empty PHP arrays), so + * we use a heuristic: if the existing value at this key is a list, an + * empty payload value clears that list; otherwise it is treated as a + * no-op. This means clients that intend to clear a list MUST already + * know the field is list-typed (which is how the modal is built). + * - **Associative-object values are merged recursively.** Leaves replace. + * - **List values replace wholesale** — RFC 7396 does not support + * per-element list patching. To remove one palette entry, send the full + * new palette. + * - **Missing parent keys are created** when assigning into them. + * + * The operational key `removedShadowDefaults` is skipped here — it's + * handled separately by `reify_shadow_removals()`. + * + * @param array $current + * @param array $payload + * @return array + */ + public static function merge( array $current, array $payload ) { + $result = $current; + foreach ( $payload as $key => $value ) { + if ( 'removedShadowDefaults' === $key ) { + continue; + } + + // RFC 7396: null deletes the key. + if ( null === $value ) { + unset( $result[ $key ] ); + continue; + } + + // Empty array: RFC 7396 says `{}` is a no-op. PHP can't tell `{}` + // from `[]`, so we infer intent from the existing value: + // - existing value is a list → caller intends to clear it. + // - otherwise (existing is assoc, missing, or scalar) → no-op. + if ( is_array( $value ) && empty( $value ) ) { + if ( isset( $result[ $key ] ) && is_array( $result[ $key ] ) && self::is_list( $result[ $key ] ) ) { + $result[ $key ] = array(); + } + continue; + } + + // Deep merge associative-object → associative-object. + if ( + is_array( $value ) && + ! self::is_list( $value ) && + isset( $result[ $key ] ) && + is_array( $result[ $key ] ) && + ! self::is_list( $result[ $key ] ) + ) { + $result[ $key ] = self::merge( $result[ $key ], $value ); + } elseif ( is_array( $value ) && self::is_list( $value ) ) { + // Normalize lists to a contiguous index. Defends against any + // sparse-keyed array slipping past validation. + $result[ $key ] = array_values( $value ); + } else { + $result[ $key ] = $value; + } + } + return $result; + } + + /** + * Translate `removedShadowDefaults: [slug, ...]` into the theme.json shape: + * `settings.shadow.defaultPresets: false` plus the kept core shadow + * defaults re-registered under `settings.shadow.presets`. User-defined + * presets (slugs that are not core defaults) are preserved. + * + * Idempotent: running the same removal twice produces the same output. + * + * @param array $merged Merged theme.json (post `merge()`). + * @param string[] $removed_slugs Slugs of core defaults to remove. + * @return array + */ + public static function reify_shadow_removals( array $merged, array $removed_slugs ) { + $core_presets = self::get_core_shadow_presets(); + if ( empty( $core_presets ) ) { + return $merged; + } + + $core_slugs = array_column( $core_presets, 'slug' ); + $kept = array_values( + array_filter( + $core_presets, + static function ( $preset ) use ( $removed_slugs ) { + return ! in_array( $preset['slug'], $removed_slugs, true ); + } + ) + ); + + $existing = isset( $merged['settings']['shadow']['presets'] ) && is_array( $merged['settings']['shadow']['presets'] ) + ? $merged['settings']['shadow']['presets'] + : array(); + + // Strip any existing presets whose slug matches a core default slug — + // we re-register the kept defaults below, so this prevents duplication. + $user_customs = array_values( + array_filter( + $existing, + static function ( $preset ) use ( $core_slugs ) { + return isset( $preset['slug'] ) && ! in_array( $preset['slug'], $core_slugs, true ); + } + ) + ); + + if ( ! isset( $merged['settings'] ) || ! is_array( $merged['settings'] ) ) { + $merged['settings'] = array(); + } + if ( ! isset( $merged['settings']['shadow'] ) || ! is_array( $merged['settings']['shadow'] ) ) { + $merged['settings']['shadow'] = array(); + } + + $merged['settings']['shadow']['defaultPresets'] = false; + $merged['settings']['shadow']['presets'] = array_merge( $user_customs, $kept ); + + return $merged; + } + + /** + * Fetch the core shadow defaults via `wp_get_global_settings`. Returns an + * empty array if core does not expose shadow defaults (older WP versions + * or unusual environments) — in which case shadow reification is a no-op + * and the caller's `defaultPresets` flag round-trips literally. + * + * `wp_get_global_settings` returns presets keyed by origin + * (`['default' => [...]]`); we extract the `default` slot. + * + * @return array + */ + public static function get_core_shadow_presets() { + if ( ! function_exists( 'wp_get_global_settings' ) ) { + return array(); + } + $presets = wp_get_global_settings( array( 'shadow', 'presets' ) ); + if ( is_array( $presets ) && isset( $presets['default'] ) && is_array( $presets['default'] ) ) { + return $presets['default']; + } + return array(); + } + + /** + * Detect whether an array is a list (sequential integer keys starting at 0). + * Mirrors PHP 8.1+ `array_is_list()`. + * + * @param array $arr + * @return bool + */ + 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; + } +} diff --git a/tests/test-theme-settings-save.php b/tests/test-theme-settings-save.php new file mode 100644 index 00000000..73bddc26 --- /dev/null +++ b/tests/test-theme-settings-save.php @@ -0,0 +1,610 @@ + array( 'color' => array( 'custom' => true ) ), + 'customTemplates' => array(), + 'templateParts' => array(), + 'removedShadowDefaults' => array( 'natural' ), + ); + $this->assertSame( $payload, CBT_Theme_Settings_Save::validate( $payload ) ); + } + + public function test_validate_rejects_unknown_top_level_key() { + $result = CBT_Theme_Settings_Save::validate( array( 'unexpected' => 1 ) ); + $this->assertWPError( $result ); + $this->assertSame( 'cbt_invalid_payload', $result->get_error_code() ); + $this->assertSame( 400, $result->get_error_data()['status'] ); + } + + public function test_validate_rejects_settings_not_array() { + $result = CBT_Theme_Settings_Save::validate( array( 'settings' => 'oops' ) ); + $this->assertWPError( $result ); + } + + public function test_validate_rejects_custom_templates_entries_not_objects() { + $result = CBT_Theme_Settings_Save::validate( + array( 'customTemplates' => array( 'not-an-object' ) ) + ); + $this->assertWPError( $result ); + } + + public function test_validate_rejects_removed_shadow_defaults_entries_not_strings() { + $result = CBT_Theme_Settings_Save::validate( + array( 'removedShadowDefaults' => array( 1 ) ) + ); + $this->assertWPError( $result ); + } + + /* ---------------------------------------------------------------- * + * sanitize() + * ---------------------------------------------------------------- */ + + public function test_sanitize_strips_tags_from_string_values() { + $out = CBT_Theme_Settings_Save::sanitize( + array( + 'settings' => array( + 'color' => array( + 'palette' => array( + array( + 'slug' => 'brand', + 'name' => 'Brand', + 'color' => '#fff', + ), + ), + ), + ), + ) + ); + $this->assertSame( 'Brand', $out['settings']['color']['palette'][0]['name'] ); + } + + public function test_sanitize_preserves_booleans_and_numbers() { + $out = CBT_Theme_Settings_Save::sanitize( + array( + 'settings' => array( + 'color' => array( + 'custom' => false, + 'defaultPalette' => true, + ), + 'spacing' => array( 'padding' => 16 ), + ), + ) + ); + $this->assertSame( false, $out['settings']['color']['custom'] ); + $this->assertSame( true, $out['settings']['color']['defaultPalette'] ); + $this->assertSame( 16, $out['settings']['spacing']['padding'] ); + } + + public function test_sanitize_preserves_camel_case_keys() { + $out = CBT_Theme_Settings_Save::sanitize( + array( 'settings' => array( 'color' => array( 'defaultPalette' => true ) ) ) + ); + $this->assertArrayHasKey( 'defaultPalette', $out['settings']['color'] ); + } + + /* ---------------------------------------------------------------- * + * merge() + * ---------------------------------------------------------------- */ + + public function test_merge_partial_settings_color_palette_leaves_other_keys_untouched() { + $current = array( + 'settings' => array( + 'color' => array( + 'palette' => array( + array( + 'slug' => 'old', + 'color' => '#000', + ), + ), + 'gradients' => array( + array( + 'slug' => 'g1', + 'gradient' => 'linear-gradient(red,blue)', + ), + ), + 'custom' => true, + ), + 'spacing' => array( 'padding' => true ), + ), + ); + $payload = array( + 'settings' => array( + 'color' => array( + 'palette' => array( + array( + 'slug' => 'new', + 'color' => '#fff', + ), + ), + ), + ), + ); + $out = CBT_Theme_Settings_Save::merge( $current, $payload ); + $this->assertSame( 'new', $out['settings']['color']['palette'][0]['slug'] ); + $this->assertSame( 'g1', $out['settings']['color']['gradients'][0]['slug'] ); + $this->assertTrue( $out['settings']['color']['custom'] ); + $this->assertTrue( $out['settings']['spacing']['padding'] ); + } + + public function test_merge_empty_palette_array_clears_palette() { + $current = array( + 'settings' => array( + 'color' => array( 'palette' => array( array( 'slug' => 'a' ) ) ), + ), + ); + $payload = array( 'settings' => array( 'color' => array( 'palette' => array() ) ) ); + $out = CBT_Theme_Settings_Save::merge( $current, $payload ); + $this->assertSame( array(), $out['settings']['color']['palette'] ); + } + + public function test_merge_creates_missing_parent_keys() { + $current = array(); // theme.json without any settings + $payload = array( + 'settings' => array( + 'color' => array( + 'palette' => array( + array( + 'slug' => 'brand', + 'color' => '#fff', + ), + ), + ), + ), + ); + $out = CBT_Theme_Settings_Save::merge( $current, $payload ); + $this->assertSame( 'brand', $out['settings']['color']['palette'][0]['slug'] ); + } + + public function test_merge_replaces_template_parts_array_wholesale() { + $current = array( + 'templateParts' => array( + array( + 'name' => 'header', + 'area' => 'header', + ), + array( + 'name' => 'footer', + 'area' => 'footer', + ), + ), + ); + $payload = array( + 'templateParts' => array( + array( + 'name' => 'sidebar', + 'area' => 'uncategorized', + ), + ), + ); + $out = CBT_Theme_Settings_Save::merge( $current, $payload ); + $this->assertCount( 1, $out['templateParts'] ); + $this->assertSame( 'sidebar', $out['templateParts'][0]['name'] ); + } + + public function test_merge_does_not_emit_removed_shadow_defaults_key() { + $out = CBT_Theme_Settings_Save::merge( + array(), + array( 'removedShadowDefaults' => array( 'natural' ) ) + ); + $this->assertArrayNotHasKey( 'removedShadowDefaults', $out ); + } + + /* ---------------------------------------------------------------- * + * merge() — RFC 7396 semantics + * ---------------------------------------------------------------- */ + + public function test_merge_null_deletes_existing_key() { + $current = array( + 'settings' => array( + 'color' => array( + 'custom' => true, + 'defaultPalette' => true, + ), + ), + ); + $payload = array( + 'settings' => array( 'color' => array( 'custom' => null ) ), + ); + $out = CBT_Theme_Settings_Save::merge( $current, $payload ); + $this->assertArrayNotHasKey( 'custom', $out['settings']['color'] ); + $this->assertTrue( $out['settings']['color']['defaultPalette'] ); + } + + public function test_merge_null_for_missing_key_is_no_op() { + $current = array( 'settings' => array( 'color' => array( 'custom' => true ) ) ); + $payload = array( 'settings' => array( 'color' => array( 'doesNotExist' => null ) ) ); + $out = CBT_Theme_Settings_Save::merge( $current, $payload ); + $this->assertTrue( $out['settings']['color']['custom'] ); + $this->assertArrayNotHasKey( 'doesNotExist', $out['settings']['color'] ); + } + + public function test_merge_empty_object_at_top_level_is_no_op() { + // The footgun the JSON-Merge-Patch contract closes: `settings: {}` must + // not wipe everything in the existing settings tree. + $current = array( + 'settings' => array( + 'color' => array( 'custom' => true ), + 'spacing' => array( 'padding' => true ), + ), + ); + $payload = array( 'settings' => array() ); + $out = CBT_Theme_Settings_Save::merge( $current, $payload ); + $this->assertSame( $current, $out ); + } + + public function test_merge_empty_object_nested_is_no_op() { + $current = array( + 'settings' => array( + 'color' => array( + 'custom' => true, + 'palette' => array( array( 'slug' => 'a' ) ), + ), + ), + ); + $payload = array( 'settings' => array( 'color' => array() ) ); + $out = CBT_Theme_Settings_Save::merge( $current, $payload ); + $this->assertSame( $current, $out ); + } + + public function test_merge_empty_for_existing_list_still_clears() { + // `palette: []` should still clear the palette, because palette is a + // list (its current value is a sequential array). + $current = array( + 'settings' => array( + 'color' => array( 'palette' => array( array( 'slug' => 'a' ) ) ), + ), + ); + $payload = array( 'settings' => array( 'color' => array( 'palette' => array() ) ) ); + $out = CBT_Theme_Settings_Save::merge( $current, $payload ); + $this->assertSame( array(), $out['settings']['color']['palette'] ); + } + + public function test_merge_empty_for_missing_list_is_no_op() { + // payload `customTemplates: []` against a theme.json that doesn't have + // a customTemplates key at all → no-op (don't create an empty list). + $current = array( 'settings' => array() ); + $payload = array( 'customTemplates' => array() ); + $out = CBT_Theme_Settings_Save::merge( $current, $payload ); + $this->assertArrayNotHasKey( 'customTemplates', $out ); + } + + /* ---------------------------------------------------------------- * + * reify_shadow_removals() + * ---------------------------------------------------------------- */ + + public function test_reify_shadow_removals_sets_default_presets_false_and_re_registers_kept() { + $core_presets = CBT_Theme_Settings_Save::get_core_shadow_presets(); + if ( empty( $core_presets ) ) { + $this->markTestSkipped( 'WP core does not expose shadow defaults in this environment.' ); + } + $out = CBT_Theme_Settings_Save::reify_shadow_removals( array(), array( $core_presets[0]['slug'] ) ); + $this->assertFalse( $out['settings']['shadow']['defaultPresets'] ); + $kept_slugs = array_column( $out['settings']['shadow']['presets'], 'slug' ); + $this->assertNotContains( $core_presets[0]['slug'], $kept_slugs ); + $this->assertCount( count( $core_presets ) - 1, $kept_slugs ); + } + + public function test_reify_shadow_removals_is_idempotent() { + $core_presets = CBT_Theme_Settings_Save::get_core_shadow_presets(); + if ( empty( $core_presets ) ) { + $this->markTestSkipped( 'WP core does not expose shadow defaults in this environment.' ); + } + $once = CBT_Theme_Settings_Save::reify_shadow_removals( array(), array( $core_presets[0]['slug'] ) ); + $twice = CBT_Theme_Settings_Save::reify_shadow_removals( $once, array( $core_presets[0]['slug'] ) ); + $this->assertSame( $once, $twice ); + } + + public function test_reify_shadow_removals_preserves_user_custom_presets() { + $core_presets = CBT_Theme_Settings_Save::get_core_shadow_presets(); + if ( empty( $core_presets ) ) { + $this->markTestSkipped( 'WP core does not expose shadow defaults in this environment.' ); + } + $user_custom = array( + 'slug' => 'my-custom-shadow', + 'name' => 'Mine', + 'shadow' => '0 0 5px #000', + ); + $current = array( + 'settings' => array( + 'shadow' => array( 'presets' => array( $user_custom ) ), + ), + ); + $out = CBT_Theme_Settings_Save::reify_shadow_removals( $current, array( $core_presets[0]['slug'] ) ); + $slugs = array_column( $out['settings']['shadow']['presets'], 'slug' ); + $this->assertContains( 'my-custom-shadow', $slugs ); + } + + /* ---------------------------------------------------------------- * + * sanitize() — context-aware (CSS values must round-trip) + * ---------------------------------------------------------------- */ + + public function test_sanitize_preserves_complex_shadow_value() { + $shadow = '6px 6px 0px -3px rgb(255, 255, 255), 6px 6px rgb(0, 0, 0)'; + $out = CBT_Theme_Settings_Save::sanitize( + array( + 'settings' => array( + 'shadow' => array( + 'presets' => array( + array( + 'slug' => 'outlined', + 'name' => 'Outlined', + 'shadow' => $shadow, + ), + ), + ), + ), + ) + ); + $this->assertSame( $shadow, $out['settings']['shadow']['presets'][0]['shadow'] ); + } + + public function test_sanitize_preserves_gradient_value() { + $gradient = 'linear-gradient(135deg, rgb(6, 147, 227) 0%, rgb(155, 81, 224) 100%)'; + $out = CBT_Theme_Settings_Save::sanitize( + array( + 'settings' => array( + 'color' => array( + 'gradients' => array( + array( + 'slug' => 'vivid', + 'name' => 'Vivid', + 'gradient' => $gradient, + ), + ), + ), + ), + ) + ); + $this->assertSame( $gradient, $out['settings']['color']['gradients'][0]['gradient'] ); + } + + public function test_sanitize_post_types_entries_are_slug_normalized() { + $out = CBT_Theme_Settings_Save::sanitize( + array( + 'customTemplates' => array( + array( + 'name' => 'page-wide', + 'title' => 'Wide Page', + 'postTypes' => array( 'Page', 'POST' ), + ), + ), + ) + ); + // `sanitize_key` lowercases. + $this->assertSame( array( 'page', 'post' ), $out['customTemplates'][0]['postTypes'] ); + } + + /* ---------------------------------------------------------------- * + * validate() — slug rejection + * ---------------------------------------------------------------- */ + + public function test_validate_rejects_removed_shadow_slug_with_invalid_chars() { + $result = CBT_Theme_Settings_Save::validate( + array( 'removedShadowDefaults' => array( 'natural', 'has spaces' ) ) + ); + $this->assertWPError( $result ); + $this->assertSame( 'cbt_invalid_payload', $result->get_error_code() ); + } + + public function test_validate_rejects_custom_template_name_with_invalid_chars() { + $result = CBT_Theme_Settings_Save::validate( + array( + 'customTemplates' => array( + array( + 'name' => 'Has Caps', + 'title' => 'Caps Page', + ), + ), + ) + ); + $this->assertWPError( $result ); + } + + public function test_validate_accepts_well_formed_slugs() { + $payload = array( + 'removedShadowDefaults' => array( 'natural', 'sharp_2' ), + 'customTemplates' => array( + array( + 'name' => 'page-wide-2', + 'title' => 'Wide Page', + ), + ), + ); + $this->assertSame( $payload, CBT_Theme_Settings_Save::validate( $payload ) ); + } + + public function test_validate_rejects_custom_template_entry_missing_title() { + $result = CBT_Theme_Settings_Save::validate( + array( 'customTemplates' => array( array( 'name' => 'page-wide' ) ) ) + ); + $this->assertWPError( $result ); + $this->assertStringContainsString( 'title', $result->get_error_message() ); + } + + public function test_validate_rejects_template_part_entry_missing_area() { + $result = CBT_Theme_Settings_Save::validate( + array( 'templateParts' => array( array( 'name' => 'sidebar' ) ) ) + ); + $this->assertWPError( $result ); + $this->assertStringContainsString( 'area', $result->get_error_message() ); + } + + public function test_validate_rejects_entry_with_empty_required_key() { + $result = CBT_Theme_Settings_Save::validate( + array( + 'customTemplates' => array( + array( + 'name' => 'page-wide', + 'title' => '', + ), + ), + ) + ); + $this->assertWPError( $result ); + } + + public function test_validate_rejects_template_part_missing_name() { + $result = CBT_Theme_Settings_Save::validate( + array( 'templateParts' => array( array( 'area' => 'header' ) ) ) + ); + $this->assertWPError( $result ); + $this->assertStringContainsString( 'name', $result->get_error_message() ); + } + + public function test_validate_accepts_complete_template_part_entry() { + $payload = array( + 'templateParts' => array( + array( + 'name' => 'sidebar', + 'area' => 'uncategorized', + ), + ), + ); + $this->assertSame( $payload, CBT_Theme_Settings_Save::validate( $payload ) ); + } + + /* ---------------------------------------------------------------- * + * validate() — JSON shape (object vs list) enforcement + * + * PHP's `json_decode(..., true)` flattens `{}` and `[]` to the same empty + * array, but a non-empty JSON list passed where an object is expected + * (or vice versa) is detectable and should be rejected. + * ---------------------------------------------------------------- */ + + public function test_validate_rejects_settings_passed_as_non_empty_list() { + $result = CBT_Theme_Settings_Save::validate( + array( 'settings' => array( array( 'foo' => 'bar' ) ) ) + ); + $this->assertWPError( $result ); + $this->assertStringContainsString( 'object', $result->get_error_message() ); + } + + public function test_validate_rejects_custom_templates_passed_as_object() { + $result = CBT_Theme_Settings_Save::validate( + array( + 'customTemplates' => array( + 'page-wide' => array( + 'name' => 'page-wide', + 'title' => 'Wide Page', + ), + ), + ) + ); + $this->assertWPError( $result ); + $this->assertStringContainsString( 'list', $result->get_error_message() ); + } + + public function test_validate_rejects_removed_shadow_defaults_passed_as_object() { + $result = CBT_Theme_Settings_Save::validate( + array( 'removedShadowDefaults' => array( 'natural' => true ) ) + ); + $this->assertWPError( $result ); + } + + public function test_validate_accepts_empty_arrays_for_either_shape() { + // Empty arrays are intentionally permitted regardless of expected + // shape — they're handled in merge() (no-op for object positions, + // clear for list positions where the existing value is a list). + $payload = array( + 'settings' => array(), + 'customTemplates' => array(), + 'templateParts' => array(), + 'removedShadowDefaults' => array(), + ); + $this->assertSame( $payload, CBT_Theme_Settings_Save::validate( $payload ) ); + } + + /* ---------------------------------------------------------------- * + * merge() — list normalization + * ---------------------------------------------------------------- */ + + public function test_merge_writes_lists_with_sequential_keys() { + // Routes a proper list payload through the `is_list`-true branch and + // confirms it lands with sequential integer keys (i.e., emits as a + // JSON list, not an object, when serialized). + $current = array(); + $payload = array( + 'customTemplates' => array( + array( + 'name' => 'a', + 'title' => 'A', + ), + array( + 'name' => 'b', + 'title' => 'B', + ), + ), + ); + $out = CBT_Theme_Settings_Save::merge( $current, $payload ); + $this->assertSame( array( 0, 1 ), array_keys( $out['customTemplates'] ) ); + } + + /* ---------------------------------------------------------------- * + * run() — write-failure path + * + * Verified via integration: a hardened service should surface a write + * failure as WP_Error rather than returning the merged payload as if it + * had been persisted. Smoke tests the unhappy path of + * `CBT_Theme_JSON_Resolver::write_theme_file_contents`. + * ---------------------------------------------------------------- */ + + public function test_run_returns_wp_error_on_write_failure() { + // Create a temp theme directory with a theme.json, then make the + // directory read-only so the atomic write (write-temp-then-rename) + // can't create its `theme.json.tmp` sibling. The service must surface + // the failure as WP_Error rather than reporting SUCCESS. + $tmp_dir = sys_get_temp_dir() . '/cbt-test-' . uniqid(); + $theme_json = $tmp_dir . '/theme.json'; + mkdir( $tmp_dir ); + file_put_contents( $theme_json, '{}' ); + chmod( $tmp_dir, 0555 ); + // Best-effort guard: skip if running as root (chmod is meaningless). + if ( is_writable( $tmp_dir ) ) { + chmod( $tmp_dir, 0755 ); + unlink( $theme_json ); + rmdir( $tmp_dir ); + $this->markTestSkipped( 'Cannot make directory read-only in this environment.' ); + } + + $filter = static function () use ( $tmp_dir ) { + return $tmp_dir; + }; + add_filter( 'stylesheet_directory', $filter ); + add_filter( 'template_directory', $filter ); + + $result = CBT_Theme_Settings_Save::run( + array( 'settings' => array( 'color' => array( 'custom' => true ) ) ) + ); + + remove_filter( 'stylesheet_directory', $filter ); + remove_filter( 'template_directory', $filter ); + + // Cleanup. + chmod( $tmp_dir, 0755 ); + unlink( $theme_json ); + rmdir( $tmp_dir ); + + $this->assertWPError( $result ); + // A read-only theme directory blocks both lockfile creation and the + // atomic write. Accept either failure code; both are correct surfaces + // for "the filesystem said no" and both protect against the original + // silent-success bug. + $this->assertContains( + $result->get_error_code(), + array( 'cbt_lock_failed', 'cbt_write_failed' ) + ); + $this->assertContains( + (int) $result->get_error_data()['status'], + array( 500, 503 ) + ); + } +}