class-avcf-abilities-base.php
3 weeks ago
class-avcf-abilities-block-navigation.php
3 weeks ago
class-avcf-abilities-cache.php
3 weeks ago
class-avcf-abilities-content.php
3 weeks ago
class-avcf-abilities-core.php
3 weeks ago
class-avcf-abilities-global-styles.php
3 weeks ago
class-avcf-abilities-gutenberg.php
3 weeks ago
class-avcf-abilities-media.php
3 weeks ago
class-avcf-abilities-metadata.php
3 weeks ago
class-avcf-abilities-navigation.php
3 weeks ago
class-avcf-abilities-patterns.php
3 weeks ago
class-avcf-abilities-plugins.php
3 weeks ago
class-avcf-abilities-settings.php
3 weeks ago
class-avcf-abilities-taxonomies.php
3 weeks ago
class-avcf-abilities-templates.php
3 weeks ago
class-avcf-abilities-theme-files.php
3 weeks ago
class-avcf-abilities-themes.php
3 weeks ago
class-avcf-abilities-users.php
3 weeks ago
class-avcf-abilities-global-styles.php
306 lines
| 1 | <?php |
| 2 | /** |
| 3 | * Global styles (theme.json user overrides) MCP abilities. |
| 4 | * |
| 5 | * Global styles are the single biggest lever for making a block-theme site |
| 6 | * look bespoke rather than default: the colour palette, typography, spacing, |
| 7 | * and per-element/per-block style rules. They are stored as a user-layer |
| 8 | * theme.json document in the wp_global_styles custom post type — invisible to |
| 9 | * the generic content and settings tools. |
| 10 | * |
| 11 | * get-global-styles reports both the PRESETS the active theme defines (the |
| 12 | * palette slugs, font families, font sizes and spacing sizes the AI may |
| 13 | * reference) and the CURRENT user overrides on top of them. update-global-styles |
| 14 | * deep-merges a partial theme.json-shaped payload into the existing overrides, |
| 15 | * sanitises it through WP_Theme_JSON (the same validation core's Site Editor |
| 16 | * uses), and saves it — so a caller can set brand colours or fonts without |
| 17 | * having to send the whole document. |
| 18 | * |
| 19 | * Colour/font/spacing values may be given as preset references |
| 20 | * ("var:preset|color|accent-1") or literals ("#0d1b2a", "1.25rem"); preset |
| 21 | * references are the portable form and are normalised to CSS custom properties |
| 22 | * on save. |
| 23 | * |
| 24 | * Exposed abilities: |
| 25 | * atarim/get-global-styles Theme presets + current user overrides. |
| 26 | * atarim/update-global-styles Deep-merge a partial theme.json into user overrides. |
| 27 | * |
| 28 | * @package atarim-visual-collaboration |
| 29 | */ |
| 30 | |
| 31 | if ( ! defined('ABSPATH') ) { |
| 32 | exit; |
| 33 | } |
| 34 | |
| 35 | class AVCF_Abilities_Global_Styles extends AVCF_Abilities_Base { |
| 36 | |
| 37 | public function register() { |
| 38 | $this->register_get(); |
| 39 | $this->register_update(); |
| 40 | } |
| 41 | |
| 42 | private function register_get() { |
| 43 | wp_register_ability( 'atarim/get-global-styles', [ |
| 44 | 'label' => 'Get Global Styles', |
| 45 | 'description' => 'Returns the site\'s global design tokens. presets are what the active theme defines and what update-global-styles can reference by slug: palette (colour slug + value + name), gradients, font_families (slug + name), font_sizes (slug + size) and spacing_sizes. user_styles and user_settings are the current theme.json overrides layered on top (empty objects when the theme defaults are untouched). Read this before update-global-styles so you use real preset slugs (e.g. reference the accent colour as "var:preset|color|accent-1") and know what is already set.', |
| 46 | 'category' => 'atarim', |
| 47 | 'input_schema' => [ |
| 48 | 'type' => 'object', |
| 49 | 'properties' => [], |
| 50 | 'additionalProperties' => false, |
| 51 | ], |
| 52 | 'output_schema' => [ |
| 53 | 'type' => 'object', |
| 54 | 'properties' => [ |
| 55 | 'success' => [ 'type' => 'boolean' ], |
| 56 | 'is_block_theme'=> [ 'type' => 'boolean' ], |
| 57 | 'presets' => [ 'type' => 'object' ], |
| 58 | 'user_styles' => [ 'type' => 'object' ], |
| 59 | 'user_settings' => [ 'type' => 'object' ], |
| 60 | 'message' => [ 'type' => 'string' ], |
| 61 | ], |
| 62 | 'required' => [ 'success', 'message' ], |
| 63 | ], |
| 64 | 'execute_callback' => function( $input = [] ) { |
| 65 | if ( ! class_exists( 'WP_Theme_JSON_Resolver' ) ) { |
| 66 | return [ 'success' => false, 'message' => 'This WordPress version does not support global styles (theme.json).' ]; |
| 67 | } |
| 68 | |
| 69 | $settings = WP_Theme_JSON_Resolver::get_theme_data()->get_settings(); |
| 70 | $presets = [ |
| 71 | 'palette' => $this->avcf_map_preset( $settings, [ 'color', 'palette' ], [ 'slug', 'color', 'name' ] ), |
| 72 | 'gradients' => $this->avcf_map_preset( $settings, [ 'color', 'gradients' ], [ 'slug', 'gradient', 'name' ] ), |
| 73 | 'font_families' => $this->avcf_map_preset( $settings, [ 'typography', 'fontFamilies' ], [ 'slug', 'name' ] ), |
| 74 | 'font_sizes' => $this->avcf_map_preset( $settings, [ 'typography', 'fontSizes' ], [ 'slug', 'size', 'name' ] ), |
| 75 | 'spacing_sizes' => $this->avcf_map_preset( $settings, [ 'spacing', 'spacingSizes' ], [ 'slug', 'size', 'name' ] ), |
| 76 | ]; |
| 77 | |
| 78 | $user = $this->avcf_read_user_global_styles(); |
| 79 | |
| 80 | return [ |
| 81 | 'success' => true, |
| 82 | 'is_block_theme'=> function_exists( 'wp_is_block_theme' ) && wp_is_block_theme(), |
| 83 | 'presets' => $presets, |
| 84 | 'user_styles' => isset( $user['styles'] ) && is_array( $user['styles'] ) ? $user['styles'] : (object) [], |
| 85 | 'user_settings' => isset( $user['settings'] ) && is_array( $user['settings'] ) ? $user['settings'] : (object) [], |
| 86 | 'message' => sprintf( '%d palette colour(s), %d font family(ies) available.', count( $presets['palette'] ), count( $presets['font_families'] ) ), |
| 87 | ]; |
| 88 | }, |
| 89 | 'permission_callback' => function() { |
| 90 | return current_user_can( 'edit_theme_options' ); |
| 91 | }, |
| 92 | 'meta' => [ |
| 93 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 94 | 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ], |
| 95 | ], |
| 96 | ] ); |
| 97 | } |
| 98 | |
| 99 | private function register_update() { |
| 100 | wp_register_ability( 'atarim/update-global-styles', [ |
| 101 | 'label' => 'Update Global Styles', |
| 102 | 'description' => 'Deep-merges a partial theme.json into the site\'s user global styles — the site-wide colours, typography, and spacing. Pass styles (theme.json "styles" shape: e.g. {"color":{"background":"var:preset|color|base","text":"var:preset|color|contrast"},"elements":{"button":{"color":{"background":"var:preset|color|accent-1"}}},"typography":{"fontFamily":"var:preset|font-family|manrope"}}) and/or settings (theme.json "settings" shape, e.g. custom palette definitions). Only the keys you pass are changed; everything else is preserved (deep merge, not replace). Values may be preset references ("var:preset|color|<slug>" — get valid slugs from get-global-styles) or literals ("#0d1b2a"). Pass null as a value to REMOVE that key from the overrides (e.g. {"elements":{"button":{"color":{"background":null}}}} drops a previously-set button colour). Pass reset:true to clear ALL user overrides and fall back to the theme defaults (the global-styles equivalent of revert-template) — reset ignores styles/settings. The payload is sanitised through WordPress\'s theme.json validator; unknown or unsafe keys are dropped. Affects every page site-wide.', |
| 103 | 'category' => 'atarim', |
| 104 | 'input_schema' => [ |
| 105 | 'type' => 'object', |
| 106 | 'properties' => [ |
| 107 | 'styles' => [ 'type' => 'object', 'description' => 'Partial theme.json "styles" object to merge. Use null values to remove keys.' ], |
| 108 | 'settings' => [ 'type' => 'object', 'description' => 'Partial theme.json "settings" object to merge. Use null values to remove keys.' ], |
| 109 | 'reset' => [ 'type' => 'boolean', 'description' => 'When true, clears ALL user overrides (styles and settings) and restores the theme defaults. Ignores styles/settings.' ], |
| 110 | ], |
| 111 | 'additionalProperties' => false, |
| 112 | ], |
| 113 | 'output_schema' => [ |
| 114 | 'type' => 'object', |
| 115 | 'properties' => [ |
| 116 | 'success' => [ 'type' => 'boolean' ], |
| 117 | 'user_styles' => [ 'type' => 'object' ], |
| 118 | 'user_settings' => [ 'type' => 'object' ], |
| 119 | 'message' => [ 'type' => 'string' ], |
| 120 | ], |
| 121 | 'required' => [ 'success', 'message' ], |
| 122 | ], |
| 123 | 'execute_callback' => function( $input = [] ) { |
| 124 | if ( ! class_exists( 'WP_Theme_JSON_Resolver' ) || ! class_exists( 'WP_Theme_JSON' ) ) { |
| 125 | return [ 'success' => false, 'message' => 'This WordPress version does not support global styles (theme.json).' ]; |
| 126 | } |
| 127 | |
| 128 | $reset = ! empty( $input['reset'] ); |
| 129 | $has_styles = isset( $input['styles'] ) && is_array( $input['styles'] ); |
| 130 | $has_settings = isset( $input['settings'] ) && is_array( $input['settings'] ); |
| 131 | if ( ! $reset && ! $has_styles && ! $has_settings ) { |
| 132 | return [ 'success' => false, 'message' => 'Provide styles and/or settings to merge, or reset:true.' ]; |
| 133 | } |
| 134 | |
| 135 | $gid = WP_Theme_JSON_Resolver::get_user_global_styles_post_id(); |
| 136 | if ( ! $gid ) { |
| 137 | return [ 'success' => false, 'message' => 'Could not resolve the user global styles record.' ]; |
| 138 | } |
| 139 | |
| 140 | $data = $this->avcf_read_user_global_styles(); |
| 141 | $version = isset( $data['version'] ) ? (int) $data['version'] : 2; |
| 142 | |
| 143 | if ( $reset ) { |
| 144 | $empty = [ 'version' => $version, 'isGlobalStylesUserThemeJSON' => true ]; |
| 145 | $saved = wp_update_post( [ 'ID' => (int) $gid, 'post_content' => wp_json_encode( $empty ) ], true ); |
| 146 | if ( is_wp_error( $saved ) ) { |
| 147 | return [ 'success' => false, 'message' => 'Reset failed: ' . $saved->get_error_message() ]; |
| 148 | } |
| 149 | WP_Theme_JSON_Resolver::clean_cached_data(); |
| 150 | return [ |
| 151 | 'success' => true, |
| 152 | 'user_styles' => (object) [], |
| 153 | 'user_settings' => (object) [], |
| 154 | 'message' => 'Global styles reset to theme defaults.', |
| 155 | ]; |
| 156 | } |
| 157 | |
| 158 | if ( $has_styles ) { |
| 159 | $data['styles'] = $this->avcf_deep_merge( isset( $data['styles'] ) ? $data['styles'] : [], $input['styles'] ); |
| 160 | } |
| 161 | if ( $has_settings ) { |
| 162 | $data['settings'] = $this->avcf_deep_merge( isset( $data['settings'] ) ? $data['settings'] : [], $input['settings'] ); |
| 163 | } |
| 164 | |
| 165 | $config = [ 'version' => $version ]; |
| 166 | if ( ! empty( $data['styles'] ) ) { |
| 167 | $config['styles'] = $data['styles']; |
| 168 | } |
| 169 | if ( ! empty( $data['settings'] ) ) { |
| 170 | $config['settings'] = $data['settings']; |
| 171 | } |
| 172 | |
| 173 | $sanitised = ( new WP_Theme_JSON( $config, 'custom' ) )->get_raw_data(); |
| 174 | $sanitised['isGlobalStylesUserThemeJSON'] = true; |
| 175 | if ( empty( $sanitised['version'] ) ) { |
| 176 | $sanitised['version'] = $version; |
| 177 | } |
| 178 | |
| 179 | $saved = wp_update_post( [ 'ID' => (int) $gid, 'post_content' => wp_json_encode( $sanitised ) ], true ); |
| 180 | if ( is_wp_error( $saved ) ) { |
| 181 | return [ 'success' => false, 'message' => 'Save failed: ' . $saved->get_error_message() ]; |
| 182 | } |
| 183 | |
| 184 | WP_Theme_JSON_Resolver::clean_cached_data(); |
| 185 | |
| 186 | return [ |
| 187 | 'success' => true, |
| 188 | 'user_styles' => isset( $sanitised['styles'] ) ? $sanitised['styles'] : (object) [], |
| 189 | 'user_settings' => isset( $sanitised['settings'] ) ? $sanitised['settings'] : (object) [], |
| 190 | 'message' => 'Global styles updated site-wide.', |
| 191 | ]; |
| 192 | }, |
| 193 | 'permission_callback' => function() { |
| 194 | return current_user_can( 'edit_theme_options' ); |
| 195 | }, |
| 196 | 'meta' => [ |
| 197 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 198 | 'annotations' => [ 'readonly' => false, 'destructive' => true, 'idempotent' => false ], |
| 199 | ], |
| 200 | ] ); |
| 201 | } |
| 202 | |
| 203 | /** |
| 204 | * Read and decode the user global styles theme.json document. |
| 205 | * |
| 206 | * @return array |
| 207 | */ |
| 208 | private function avcf_read_user_global_styles() { |
| 209 | $gid = WP_Theme_JSON_Resolver::get_user_global_styles_post_id(); |
| 210 | if ( ! $gid ) { |
| 211 | return []; |
| 212 | } |
| 213 | $data = json_decode( (string) get_post( $gid )->post_content, true ); |
| 214 | return is_array( $data ) ? $data : []; |
| 215 | } |
| 216 | |
| 217 | /** |
| 218 | * Extract a preset group from a settings array into a lean list, keeping |
| 219 | * only the requested keys from each entry. |
| 220 | * |
| 221 | * @param array $settings |
| 222 | * @param array $path Path into the settings array, e.g. ['color','palette']. |
| 223 | * @param array $keys Keys to keep from each preset entry. |
| 224 | * @return array<int,array> |
| 225 | */ |
| 226 | private function avcf_map_preset( $settings, $path, $keys ) { |
| 227 | $node = $settings; |
| 228 | foreach ( $path as $segment ) { |
| 229 | if ( ! is_array( $node ) || ! isset( $node[ $segment ] ) ) { |
| 230 | return []; |
| 231 | } |
| 232 | $node = $node[ $segment ]; |
| 233 | } |
| 234 | |
| 235 | // Presets can be grouped by origin (theme/default/custom) or be a flat list. |
| 236 | if ( isset( $node['theme'] ) || isset( $node['default'] ) || isset( $node['custom'] ) ) { |
| 237 | $flat = []; |
| 238 | foreach ( [ 'theme', 'custom', 'default' ] as $origin ) { |
| 239 | if ( ! empty( $node[ $origin ] ) && is_array( $node[ $origin ] ) ) { |
| 240 | $flat = array_merge( $flat, $node[ $origin ] ); |
| 241 | } |
| 242 | } |
| 243 | $node = $flat; |
| 244 | } |
| 245 | |
| 246 | $out = []; |
| 247 | foreach ( (array) $node as $entry ) { |
| 248 | if ( ! is_array( $entry ) ) { |
| 249 | continue; |
| 250 | } |
| 251 | $picked = []; |
| 252 | foreach ( $keys as $k ) { |
| 253 | if ( isset( $entry[ $k ] ) ) { |
| 254 | $picked[ $k ] = $entry[ $k ]; |
| 255 | } |
| 256 | } |
| 257 | if ( ! empty( $picked ) ) { |
| 258 | $out[] = $picked; |
| 259 | } |
| 260 | } |
| 261 | return $out; |
| 262 | } |
| 263 | |
| 264 | /** |
| 265 | * Recursively merge $overrides into $base. Associative arrays merge by key; |
| 266 | * scalars and lists overwrite. Used to layer a partial theme.json onto the |
| 267 | * existing user document without clobbering untouched branches. |
| 268 | * |
| 269 | * @param array $base |
| 270 | * @param array $overrides |
| 271 | * @return array |
| 272 | */ |
| 273 | private function avcf_deep_merge( $base, $overrides ) { |
| 274 | foreach ( $overrides as $key => $value ) { |
| 275 | if ( $value === null ) { |
| 276 | unset( $base[ $key ] ); |
| 277 | continue; |
| 278 | } |
| 279 | if ( is_array( $value ) && isset( $base[ $key ] ) && is_array( $base[ $key ] ) && $this->avcf_is_assoc( $value ) ) { |
| 280 | $merged = $this->avcf_deep_merge( $base[ $key ], $value ); |
| 281 | if ( $merged === [] ) { |
| 282 | unset( $base[ $key ] ); |
| 283 | } else { |
| 284 | $base[ $key ] = $merged; |
| 285 | } |
| 286 | continue; |
| 287 | } |
| 288 | $base[ $key ] = $value; |
| 289 | } |
| 290 | return $base; |
| 291 | } |
| 292 | |
| 293 | /** |
| 294 | * Whether an array is associative (string keys) rather than a list. |
| 295 | * |
| 296 | * @param array $arr |
| 297 | * @return bool |
| 298 | */ |
| 299 | private function avcf_is_assoc( $arr ) { |
| 300 | if ( $arr === [] ) { |
| 301 | return false; |
| 302 | } |
| 303 | return array_keys( $arr ) !== range( 0, count( $arr ) - 1 ); |
| 304 | } |
| 305 | } |
| 306 |