Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
69.34% |
303 / 437 |
|
31.58% |
6 / 19 |
CRAP | |
0.00% |
0 / 1 |
| GlobalStyles | |
69.34% |
303 / 437 |
|
31.58% |
6 / 19 |
496.30 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| register_abilities | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| register_get_global_styles | |
87.50% |
28 / 32 |
|
0.00% |
0 / 1 |
1.00 | |||
| register_update_global_styles | |
100.00% |
82 / 82 |
|
100.00% |
1 / 1 |
1 | |||
| register_get_active_global_styles | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
3 | |||
| register_get_active_global_styles_id | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
3 | |||
| execute_update_global_styles | |
90.00% |
18 / 20 |
|
0.00% |
0 / 1 |
10.10 | |||
| diff_requested_vs_stored | |
60.00% |
21 / 35 |
|
0.00% |
0 / 1 |
5.02 | |||
| detect_misplaced_application | |
37.50% |
6 / 16 |
|
0.00% |
0 / 1 |
14.79 | |||
| walk_leaves | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
12.04 | |||
| lookup_path | |
68.57% |
24 / 35 |
|
0.00% |
0 / 1 |
23.95 | |||
| values_match | |
57.14% |
4 / 7 |
|
0.00% |
0 / 1 |
10.86 | |||
| collect_known_preset_slugs | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
14 | |||
| flatten_origin_buckets | |
87.50% |
14 / 16 |
|
0.00% |
0 / 1 |
10.20 | |||
| detect_unresolved_preset_reference | |
13.33% |
2 / 15 |
|
0.00% |
0 / 1 |
38.90 | |||
| build_registration_snippet | |
0.00% |
0 / 61 |
|
0.00% |
0 / 1 |
30 | |||
| explain_missing_leaf | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
| coerce_to_array | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| iter_per_block_and_global_settings | |
61.54% |
8 / 13 |
|
0.00% |
0 / 1 |
11.64 | |||
| 1 | <?php |
| 2 | declare( strict_types=1 ); |
| 3 | |
| 4 | namespace BLU\Abilities; |
| 5 | |
| 6 | /** |
| 7 | * Global Styles class |
| 8 | * |
| 9 | * Registers abilities for getting and updating WordPress global styles. |
| 10 | * Global styles are part of the Full Site Editing (FSE) system and contain |
| 11 | * theme.json configuration and user customizations. |
| 12 | */ |
| 13 | class GlobalStyles { |
| 14 | |
| 15 | /** |
| 16 | * Constructor |
| 17 | */ |
| 18 | public function __construct() { |
| 19 | $this->register_abilities(); |
| 20 | } |
| 21 | |
| 22 | /** |
| 23 | * Register all global styles abilities |
| 24 | * |
| 25 | * @return void |
| 26 | */ |
| 27 | private function register_abilities(): void { |
| 28 | $this->register_get_global_styles(); |
| 29 | $this->register_update_global_styles(); |
| 30 | $this->register_get_active_global_styles(); |
| 31 | $this->register_get_active_global_styles_id(); |
| 32 | } |
| 33 | |
| 34 | /** |
| 35 | * Register ability to get a specific global styles configuration |
| 36 | * |
| 37 | * @return void |
| 38 | */ |
| 39 | private function register_get_global_styles(): void { |
| 40 | blu_register_ability( |
| 41 | 'blu/get-global-styles', |
| 42 | array( |
| 43 | 'label' => 'Get Global Styles', |
| 44 | 'description' => 'Get a specific global styles configuration by ID. Returns theme.json settings and user customizations including colors, typography, and spacing. Only use this when you need to inspect the current styles — do NOT call this before blu/update-global-styles, which resolves the ID automatically.', |
| 45 | 'category' => 'blu-mcp', |
| 46 | 'input_schema' => array( |
| 47 | 'type' => 'object', |
| 48 | 'properties' => array( |
| 49 | 'id' => array( |
| 50 | 'type' => 'integer', |
| 51 | 'description' => 'Global styles ID', |
| 52 | ), |
| 53 | ), |
| 54 | 'required' => array( 'id' ), |
| 55 | ), |
| 56 | 'execute_callback' => function ( $input ) { |
| 57 | $id = intval( $input['id'] ); |
| 58 | $request = new \WP_REST_Request( 'GET', '/wp/v2/global-styles/' . $id ); |
| 59 | $response = rest_do_request( $request ); |
| 60 | return blu_standardize_rest_response( $response ); |
| 61 | }, |
| 62 | 'permission_callback' => fn() => current_user_can( 'edit_theme_options' ), |
| 63 | 'meta' => array( |
| 64 | 'annotations' => array( |
| 65 | 'readonly' => true, |
| 66 | 'destructive' => false, |
| 67 | 'idempotent' => true, |
| 68 | ), |
| 69 | ), |
| 70 | ) |
| 71 | ); |
| 72 | } |
| 73 | |
| 74 | /** |
| 75 | * Register ability to update global styles |
| 76 | * |
| 77 | * @return void |
| 78 | */ |
| 79 | private function register_update_global_styles(): void { |
| 80 | blu_register_ability( |
| 81 | 'blu/update-global-styles', |
| 82 | array( |
| 83 | 'label' => 'Update Global Styles', |
| 84 | 'description' => 'Update WordPress global styles (colors, typography, spacing) using theme.json format. Resolves the global styles ID automatically — do NOT call get-global-styles first. Use for site-wide palette, font, or spacing changes — NOT for individual block colors. SETTINGS vs STYLES — these are NOT interchangeable: "settings" REGISTERS what is available (adds a font to the picker, declares a palette slot); writing only to "settings" does NOT change what visitors see. "styles" APPLIES values to the rendered page (sets the active font, sets the active background). To change the font users actually see you almost always need BOTH: register the family under settings.typography.fontFamilies AND apply it under styles.typography.fontFamily (site-wide) and/or styles.blocks.<block>.typography.fontFamily (per block). Per-block APPLICATION (the active font/color for a specific block) lives under styles.blocks.*; per-block REGISTRATION (settings.blocks.<block>.typography.fontFamilies, settings.blocks.<block>.appearanceTools, etc.) is a real WP feature and stays under settings.blocks.*. The common mistake is putting an application value (typography.fontFamily singular, color.background, color.text, etc.) under settings.blocks.<block> — that has no visual effect; move it to styles.blocks.<block>.<same-sub-path>. PRESET REFERENCES use the literal token "var:preset|font-family|<slug>" (note "font-family", not "font"); for colors use "var:preset|color|<slug>". The CSS-variable form var(--wp--preset--font-family--<slug>) also works and is equivalent. FONT CHANGE EXAMPLE — apply Fira Code site-wide and to common blocks: {"settings":{"typography":{"fontFamilies":[{"slug":"fira-code","name":"Fira Code","fontFamily":"\"Fira Code\", monospace"}]}},"styles":{"typography":{"fontFamily":"var:preset|font-family|fira-code"},"blocks":{"core/heading":{"typography":{"fontFamily":"var:preset|font-family|fira-code"}},"core/paragraph":{"typography":{"fontFamily":"var:preset|font-family|fira-code"}},"core/button":{"typography":{"fontFamily":"var:preset|font-family|fira-code"}}}}}. The response includes an "applied" list (paths that landed) and a "not_applied" list (paths sent but not in effect, each with a reason). If "not_applied" is non-empty the change did NOT fully succeed — fix the payload and retry; do NOT report success to the user. COLOR SLUGS: base=Background, base-midtone=Background midtone, contrast=Text, contrast-midtone=Text midtone, accent-2=Primary, accent-5=Secondary. Only include slugs you are changing — others are preserved. MIDTONE COLORS: When changing base, also update base-midtone (a subtle step toward contrast). When changing contrast, also update contrast-midtone (a subtle step toward base). Light theme example: base=#ffffff, base-midtone=#f4f4f4, contrast=#000000, contrast-midtone=#323232. Dark theme example: base=#181818, base-midtone=#1C1C1C, contrast=#FFFFFF, contrast-midtone=#DADADA. ACCENT COLORS: Generate ALL 6 shades via HSL lightness from the base color: accent-1(-24%), accent-2(base), accent-3(+18%), accent-4(+28%), accent-5(+56%), accent-6(+63%). Example for deep blue #0B3D5B: accent-1=#062533, accent-2=#0B3D5B, accent-3=#1A5A7A, accent-4=#2A7399, accent-5=#6BAAC9, accent-6=#8DC1D9. DARK/LIGHT MODE: Only change base + base-midtone + contrast + contrast-midtone, NEVER modify accents. "base" must be white/near-white for light or dark grey for dark themes. "contrast" must be the opposite. VAGUE PALETTE REQUESTS: When user says "change colors" without specifying which, ask what colors or mood they want first — do not apply immediately. PALETTE FORMAT: {"settings":{"color":{"palette":{"theme":[{"slug":"...","color":"#hex","name":"..."}]}}}}', |
| 85 | 'category' => 'blu-mcp', |
| 86 | 'input_schema' => array( |
| 87 | 'type' => 'object', |
| 88 | 'properties' => array( |
| 89 | 'settings' => array( |
| 90 | 'type' => 'object', |
| 91 | 'description' => 'Settings object in theme.json format.', |
| 92 | 'properties' => array( |
| 93 | 'color' => array( |
| 94 | 'type' => 'object', |
| 95 | 'description' => 'Color settings.', |
| 96 | 'properties' => array( |
| 97 | 'palette' => array( |
| 98 | 'type' => 'object', |
| 99 | 'description' => 'Palette settings.', |
| 100 | 'properties' => array( |
| 101 | 'theme' => array( |
| 102 | 'type' => 'array', |
| 103 | 'description' => 'Array of theme palette color entries. Only include slugs you are changing.', |
| 104 | 'items' => array( |
| 105 | 'type' => 'object', |
| 106 | 'properties' => array( |
| 107 | 'slug' => array( |
| 108 | 'type' => 'string', |
| 109 | 'description' => 'Color slug: base, base-midtone, contrast, contrast-midtone, accent-1 through accent-6.', |
| 110 | ), |
| 111 | 'color' => array( |
| 112 | 'type' => 'string', |
| 113 | 'description' => 'Hex color value (e.g. #0B3D5B).', |
| 114 | ), |
| 115 | 'name' => array( |
| 116 | 'type' => 'string', |
| 117 | 'description' => 'Display name for the color.', |
| 118 | ), |
| 119 | ), |
| 120 | 'required' => array( 'slug', 'color', 'name' ), |
| 121 | ), |
| 122 | ), |
| 123 | ), |
| 124 | ), |
| 125 | ), |
| 126 | ), |
| 127 | 'typography' => array( |
| 128 | 'type' => 'object', |
| 129 | 'description' => 'Typography settings (fontFamilies, fontSizes).', |
| 130 | ), |
| 131 | 'spacing' => array( |
| 132 | 'type' => 'object', |
| 133 | 'description' => 'Spacing settings.', |
| 134 | ), |
| 135 | ), |
| 136 | ), |
| 137 | 'styles' => array( |
| 138 | 'type' => 'object', |
| 139 | 'description' => 'Styles object containing CSS-like declarations for root, elements, and blocks.', |
| 140 | ), |
| 141 | ), |
| 142 | 'anyOf' => array( |
| 143 | array( 'required' => array( 'settings' ) ), |
| 144 | array( 'required' => array( 'styles' ) ), |
| 145 | ), |
| 146 | ), |
| 147 | 'execute_callback' => array( $this, 'execute_update_global_styles' ), |
| 148 | 'permission_callback' => fn() => current_user_can( 'edit_theme_options' ), |
| 149 | 'meta' => array( |
| 150 | 'annotations' => array( |
| 151 | 'readonly' => false, |
| 152 | 'destructive' => true, |
| 153 | 'idempotent' => true, |
| 154 | ), |
| 155 | 'mcp' => array( |
| 156 | 'public' => true, |
| 157 | 'type' => 'tool', |
| 158 | ), |
| 159 | ), |
| 160 | ) |
| 161 | ); |
| 162 | } |
| 163 | |
| 164 | /** |
| 165 | * Register ability to get active global styles for the current theme |
| 166 | * |
| 167 | * @return void |
| 168 | */ |
| 169 | private function register_get_active_global_styles(): void { |
| 170 | blu_register_ability( |
| 171 | 'blu/get-active-global-styles', |
| 172 | array( |
| 173 | 'label' => 'Get Active Global Styles', |
| 174 | 'description' => 'Get the currently active global styles for the current theme, including colors, typography, and spacing. Use for: "show colors", "what fonts are available", "current palette", "list styles". Returns the full styles object with all active customizations.', |
| 175 | 'category' => 'blu-mcp', |
| 176 | 'input_schema' => array( |
| 177 | 'type' => 'object', |
| 178 | ), |
| 179 | 'execute_callback' => function () { |
| 180 | $global_styles = wp_get_global_styles(); |
| 181 | |
| 182 | return is_array( $global_styles ) && ! empty( $global_styles ) |
| 183 | ? blu_prepare_ability_response( 200, $global_styles ) |
| 184 | : blu_prepare_ability_response( 404, 'No active global styles found.' ); |
| 185 | }, |
| 186 | 'permission_callback' => fn() => current_user_can( 'edit_theme_options' ), |
| 187 | 'meta' => array( |
| 188 | 'annotations' => array( |
| 189 | 'readonly' => true, |
| 190 | 'destructive' => false, |
| 191 | 'idempotent' => true, |
| 192 | ), |
| 193 | ), |
| 194 | ) |
| 195 | ); |
| 196 | } |
| 197 | |
| 198 | /** |
| 199 | * Register ability to get active global styles ID for the current theme |
| 200 | * |
| 201 | * @return void |
| 202 | */ |
| 203 | private function register_get_active_global_styles_id(): void { |
| 204 | blu_register_ability( |
| 205 | 'blu/get-active-global-styles-id', |
| 206 | array( |
| 207 | 'label' => 'Get Active Global Styles ID', |
| 208 | 'description' => 'Get the active global styles post ID for the current theme. Only use this when you need the ID for reference — do NOT call this before blu/update-global-styles, which resolves the ID automatically. Returns an object with the numeric ID.', |
| 209 | 'category' => 'blu-mcp', |
| 210 | 'input_schema' => array( |
| 211 | 'type' => 'object', |
| 212 | ), |
| 213 | 'execute_callback' => function () { |
| 214 | $id = \WP_Theme_JSON_Resolver::get_user_global_styles_post_id(); |
| 215 | |
| 216 | return is_int( $id ) && $id > 0 |
| 217 | ? blu_prepare_ability_response( 200, array( 'id' => $id ) ) |
| 218 | : blu_prepare_ability_response( 404, 'No active global styles ID found.' ); |
| 219 | }, |
| 220 | 'permission_callback' => fn() => current_user_can( 'edit_theme_options' ), |
| 221 | 'meta' => array( |
| 222 | 'annotations' => array( |
| 223 | 'readonly' => true, |
| 224 | 'destructive' => false, |
| 225 | 'idempotent' => true, |
| 226 | ), |
| 227 | ), |
| 228 | ) |
| 229 | ); |
| 230 | } |
| 231 | |
| 232 | /** |
| 233 | * Execute update global styles ability. |
| 234 | * |
| 235 | * Sends the requested settings/styles to the WP REST endpoint, then diffs the |
| 236 | * stored result against the request so callers can see what actually landed. |
| 237 | * |
| 238 | * The standardized response always includes (alongside the usual statusCode / |
| 239 | * status / message fields) an `applied` array of dot-paths that were written |
| 240 | * and a `not_applied` array of dot-paths that were sent but did not take |
| 241 | * effect. Each not_applied entry carries a `path` and a `reason` so the |
| 242 | * caller can self-correct without round-tripping through a follow-up read. |
| 243 | * |
| 244 | * @param array $input The input parameters. |
| 245 | * @return array The result. |
| 246 | */ |
| 247 | public function execute_update_global_styles( array $input = array() ): array { |
| 248 | if ( ! isset( $input['settings'] ) && ! isset( $input['styles'] ) ) { |
| 249 | return blu_prepare_ability_response( 400, 'Settings or styles object is required' ); |
| 250 | } |
| 251 | |
| 252 | $global_styles_id = \WP_Theme_JSON_Resolver::get_user_global_styles_post_id(); |
| 253 | |
| 254 | if ( ! $global_styles_id ) { |
| 255 | return blu_prepare_ability_response( 500, 'Could not find global styles post' ); |
| 256 | } |
| 257 | |
| 258 | $request = new \WP_REST_Request( 'POST', '/wp/v2/global-styles/' . $global_styles_id ); |
| 259 | |
| 260 | // Prepare the update data. |
| 261 | $data = array(); |
| 262 | if ( isset( $input['settings'] ) ) { |
| 263 | $data['settings'] = $input['settings']; |
| 264 | } |
| 265 | if ( isset( $input['styles'] ) ) { |
| 266 | $data['styles'] = $input['styles']; |
| 267 | } |
| 268 | |
| 269 | $request->set_body_params( $data ); |
| 270 | $response = rest_do_request( $request ); |
| 271 | |
| 272 | $result = blu_standardize_rest_response( $response ); |
| 273 | |
| 274 | // Only attach the applied/not_applied diff on a successful 2xx response. |
| 275 | // On error, the message already carries the REST error string; surfacing |
| 276 | // a diff against an unwritten post would be misleading. |
| 277 | if ( isset( $result['statusCode'] ) && $result['statusCode'] >= 200 && $result['statusCode'] < 300 ) { |
| 278 | $stored = is_array( $result['message'] ?? null ) ? $result['message'] : array(); |
| 279 | $diff = $this->diff_requested_vs_stored( $data, $stored ); |
| 280 | |
| 281 | $result['applied'] = $diff['applied']; |
| 282 | $result['not_applied'] = $diff['not_applied']; |
| 283 | } |
| 284 | |
| 285 | return $result; |
| 286 | } |
| 287 | |
| 288 | /** |
| 289 | * Compare what was requested against what the stored post actually contains. |
| 290 | * |
| 291 | * Walks every leaf in `$requested` and looks up the same path in the stored |
| 292 | * settings/styles payload returned by the REST endpoint. Leaves whose stored |
| 293 | * value matches the requested value land in `applied`; everything else lands |
| 294 | * in `not_applied` with a human-readable reason — wrong key path, dropped |
| 295 | * unknown key, or preset slug that was never registered. |
| 296 | * |
| 297 | * @param array $requested Top-level payload submitted to the REST endpoint (keys: settings, styles). |
| 298 | * @param array $stored Stored post payload returned by the REST endpoint. |
| 299 | * @return array{applied: string[], not_applied: array<int, array{path: string, reason: string}>} |
| 300 | */ |
| 301 | private function diff_requested_vs_stored( array $requested, array $stored ): array { |
| 302 | $applied = array(); |
| 303 | $not_applied = array(); |
| 304 | |
| 305 | // Collect preset slugs FIRST, across the full request (top-level AND |
| 306 | // per-block settings). A reference to a slug registered under |
| 307 | // settings.blocks.<block>.typography.fontFamilies must not be flagged |
| 308 | // as unknown. |
| 309 | $preset_slugs = $this->collect_known_preset_slugs( $requested, $stored ); |
| 310 | |
| 311 | $this->walk_leaves( |
| 312 | $requested, |
| 313 | '', |
| 314 | function ( string $path, $requested_value ) use ( $stored, $preset_slugs, &$applied, &$not_applied ) { |
| 315 | // Application-shaped leaves placed anywhere under settings.* |
| 316 | // (e.g. settings.typography.fontFamily, or |
| 317 | // settings.blocks.core/paragraph.typography.fontFamily) have |
| 318 | // no effect — they belong under the parallel styles.* path. |
| 319 | // Flag this before consulting storage: even if WP kept the |
| 320 | // value byte-for-byte, it is rendered as inert metadata. |
| 321 | $misplaced = $this->detect_misplaced_application( $path, $requested_value ); |
| 322 | if ( null !== $misplaced ) { |
| 323 | $not_applied[] = array( |
| 324 | 'path' => $path, |
| 325 | 'reason' => $misplaced, |
| 326 | ); |
| 327 | return; |
| 328 | } |
| 329 | |
| 330 | $stored_value = $this->lookup_path( $stored, $path ); |
| 331 | |
| 332 | // Even when the value lands in storage byte-for-byte, a preset |
| 333 | // reference that points at nothing (wrong token, or unknown |
| 334 | // slug) will not resolve at render time. Surface it as |
| 335 | // not_applied so the caller does not claim success on a no-op. |
| 336 | $preset_problem = $this->detect_unresolved_preset_reference( $path, $requested_value, $preset_slugs ); |
| 337 | if ( null !== $preset_problem ) { |
| 338 | $not_applied[] = array( |
| 339 | 'path' => $path, |
| 340 | 'reason' => $preset_problem, |
| 341 | ); |
| 342 | return; |
| 343 | } |
| 344 | |
| 345 | if ( $this->values_match( $requested_value, $stored_value ) ) { |
| 346 | $applied[] = $path; |
| 347 | return; |
| 348 | } |
| 349 | |
| 350 | $not_applied[] = array( |
| 351 | 'path' => $path, |
| 352 | 'reason' => $this->explain_missing_leaf( $stored_value ), |
| 353 | ); |
| 354 | } |
| 355 | ); |
| 356 | |
| 357 | return array( |
| 358 | 'applied' => $applied, |
| 359 | 'not_applied' => $not_applied, |
| 360 | ); |
| 361 | } |
| 362 | |
| 363 | /** |
| 364 | * Detect a leaf placed under `settings.*` whose terminal sub-path is a |
| 365 | * scalar application key (the kind that belongs under `styles.*`). Covers |
| 366 | * both the top-level case (`settings.typography.fontFamily`) and the |
| 367 | * per-block case (`settings.blocks.<block>.typography.fontFamily`) — |
| 368 | * both produce inert metadata that has no visual effect. |
| 369 | * |
| 370 | * Deliberately narrow: only catches high-confidence "I tried to set the |
| 371 | * active font/color by writing to settings" mistakes. Real registrations |
| 372 | * (`settings.typography.fontFamilies` plural, `settings.color.palette`, |
| 373 | * `settings.appearanceTools`, per-block analogues) are NOT flagged. |
| 374 | * |
| 375 | * When the value itself uses the wrong preset token (`var:preset|font|...` |
| 376 | * instead of `var:preset|font-family|...`), the reason includes that |
| 377 | * correction too — the agent should not need a second round-trip to fix |
| 378 | * two adjacent mistakes in the same leaf. |
| 379 | * |
| 380 | * @param string $path Dot-path of the leaf being visited. |
| 381 | * @param mixed $value Requested leaf value. |
| 382 | * @return string|null Reason text, or null when the leaf is not a misplaced application. |
| 383 | */ |
| 384 | private function detect_misplaced_application( string $path, $value ): ?string { |
| 385 | $app_keys = 'typography\.font(?:Family|Size|Style|Weight)' |
| 386 | . '|typography\.(?:lineHeight|letterSpacing|textDecoration|textTransform)' |
| 387 | . '|color\.(?:background|text|gradient)'; |
| 388 | |
| 389 | // Two shapes, captured into the same `sub` group: |
| 390 | // settings.<sub> |
| 391 | // settings.blocks.<block>.<sub> |
| 392 | $pattern = "#^settings\\.(?:blocks\\.(?P<block>[^.]+)\\.)?(?P<sub>{$app_keys})$#"; |
| 393 | |
| 394 | if ( ! preg_match( $pattern, $path, $m ) ) { |
| 395 | return null; |
| 396 | } |
| 397 | |
| 398 | $block = $m['block'] ?? ''; |
| 399 | $sub = $m['sub']; |
| 400 | $target_path = '' === $block |
| 401 | ? "styles.{$sub}" |
| 402 | : "styles.blocks.{$block}.{$sub}"; |
| 403 | $source_scope = '' === $block ? 'settings.*' : 'settings.blocks.*'; |
| 404 | |
| 405 | $reason = "'{$path}' applies a value under {$source_scope} — that subtree is treated as registration metadata, so the value will not render. Move it to '{$target_path}' and resend."; |
| 406 | |
| 407 | // Adjacent mistake: wrong preset token. Mention the fix in the same |
| 408 | // reason so the agent does not have to round-trip twice. |
| 409 | if ( is_string( $value ) && preg_match( '/^var:preset\|font\|(.+)$/', $value, $vm ) ) { |
| 410 | $reason .= " The value also uses the wrong preset token — use 'var:preset|font-family|{$vm[1]}' (not 'font') when you resend."; |
| 411 | } |
| 412 | |
| 413 | return $reason; |
| 414 | } |
| 415 | |
| 416 | /** |
| 417 | * Recursively walk every scalar/leaf inside `$value`, invoking `$visit` with |
| 418 | * a dot-delimited path. Numerically-indexed lists (e.g. fontFamilies) are |
| 419 | * keyed by `slug` when each item has one so paths stay stable across |
| 420 | * reorderings — otherwise we fall back to the numeric index. |
| 421 | * |
| 422 | * @param mixed $value Node to walk. |
| 423 | * @param string $path Path accumulated so far. |
| 424 | * @param callable $visit Receives (path, leaf_value). |
| 425 | * @return void |
| 426 | */ |
| 427 | private function walk_leaves( $value, string $path, callable $visit ): void { |
| 428 | if ( is_array( $value ) ) { |
| 429 | // An empty array carries no leaves to compare; do not visit, do not |
| 430 | // recurse — it would falsely register as a missing scalar. |
| 431 | if ( empty( $value ) ) { |
| 432 | return; |
| 433 | } |
| 434 | |
| 435 | $is_list = array_keys( $value ) === range( 0, count( $value ) - 1 ); |
| 436 | |
| 437 | foreach ( $value as $key => $child ) { |
| 438 | if ( $is_list ) { |
| 439 | // Slugs are used as a stable identifier in the path, but |
| 440 | // only when they look like a slug. Anything containing |
| 441 | // path-significant characters (`.`, `[`, `]`, `=`) falls |
| 442 | // back to the numeric index so lookup_path does not |
| 443 | // mis-parse. |
| 444 | $slug = is_array( $child ) && isset( $child['slug'] ) && is_string( $child['slug'] ) ? $child['slug'] : null; |
| 445 | $child_key = ( null !== $slug && preg_match( '/^[A-Za-z0-9_-]+$/', $slug ) ) |
| 446 | ? '[slug=' . $slug . ']' |
| 447 | : '[' . $key . ']'; |
| 448 | } else { |
| 449 | $child_key = (string) $key; |
| 450 | } |
| 451 | |
| 452 | $next_path = '' === $path ? $child_key : $path . ( str_starts_with( $child_key, '[' ) ? $child_key : '.' . $child_key ); |
| 453 | $this->walk_leaves( $child, $next_path, $visit ); |
| 454 | } |
| 455 | return; |
| 456 | } |
| 457 | |
| 458 | $visit( $path, $value ); |
| 459 | } |
| 460 | |
| 461 | /** |
| 462 | * Look up the value at `$path` inside `$haystack`. Returns null when any |
| 463 | * segment is missing. Mirrors the path syntax produced by `walk_leaves` |
| 464 | * (dot-delimited keys, `[slug=...]` and `[N]` for list items). |
| 465 | * |
| 466 | * @param array $haystack Stored payload. |
| 467 | * @param string $path Dot-delimited path. |
| 468 | * @return mixed Value at the path, or null when absent. |
| 469 | */ |
| 470 | private function lookup_path( array $haystack, string $path ) { |
| 471 | if ( '' === $path ) { |
| 472 | return $haystack; |
| 473 | } |
| 474 | |
| 475 | // Split on '.' and '[' boundaries while preserving the bracket segment. |
| 476 | preg_match_all( '/\[[^\]]+\]|[^.\[]+/', $path, $matches ); |
| 477 | $segments = $matches[0]; |
| 478 | |
| 479 | $cursor = $haystack; |
| 480 | foreach ( $segments as $segment ) { |
| 481 | // WP_REST_Global_Styles_Controller emits stdClass for empty |
| 482 | // settings/styles subtrees. Coerce so the traversal does not |
| 483 | // false-negative every leaf below. |
| 484 | if ( is_object( $cursor ) ) { |
| 485 | $cursor = (array) $cursor; |
| 486 | } |
| 487 | if ( ! is_array( $cursor ) ) { |
| 488 | return null; |
| 489 | } |
| 490 | |
| 491 | if ( str_starts_with( $segment, '[' ) ) { |
| 492 | // WP normalises user-supplied preset lists (font families, |
| 493 | // colours, etc.) into `{custom, theme, default}` "origin" |
| 494 | // buckets. Callers post a flat list; the stored result is |
| 495 | // wrapped. Flatten before any list-indexed lookup so the |
| 496 | // requested path still resolves. |
| 497 | $cursor = $this->flatten_origin_buckets( $cursor ); |
| 498 | if ( ! is_array( $cursor ) ) { |
| 499 | return null; |
| 500 | } |
| 501 | |
| 502 | if ( str_starts_with( $segment, '[slug=' ) ) { |
| 503 | $slug = substr( $segment, 6, -1 ); |
| 504 | $found = null; |
| 505 | foreach ( $cursor as $item ) { |
| 506 | $item = is_object( $item ) ? (array) $item : $item; |
| 507 | if ( is_array( $item ) && isset( $item['slug'] ) && $item['slug'] === $slug ) { |
| 508 | $found = $item; |
| 509 | break; |
| 510 | } |
| 511 | } |
| 512 | if ( null === $found ) { |
| 513 | return null; |
| 514 | } |
| 515 | $cursor = $found; |
| 516 | continue; |
| 517 | } |
| 518 | |
| 519 | $index = (int) substr( $segment, 1, -1 ); |
| 520 | if ( ! array_key_exists( $index, $cursor ) ) { |
| 521 | return null; |
| 522 | } |
| 523 | $cursor = $cursor[ $index ]; |
| 524 | continue; |
| 525 | } |
| 526 | |
| 527 | if ( ! array_key_exists( $segment, $cursor ) ) { |
| 528 | return null; |
| 529 | } |
| 530 | $cursor = $cursor[ $segment ]; |
| 531 | } |
| 532 | |
| 533 | return $cursor; |
| 534 | } |
| 535 | |
| 536 | /** |
| 537 | * Loose equality for diff comparison. Scalars compare with `==` so that |
| 538 | * `"42"` and `42` agree (WP REST sometimes restringifies numerics). Arrays |
| 539 | * compare structurally after stringification of leaves; we only reach this |
| 540 | * helper for scalars in practice because `walk_leaves` recurses into arrays |
| 541 | * before invoking the visitor. |
| 542 | * |
| 543 | * @param mixed $requested Requested leaf value. |
| 544 | * @param mixed $stored Stored leaf value. |
| 545 | * @return bool |
| 546 | */ |
| 547 | private function values_match( $requested, $stored ): bool { |
| 548 | if ( null === $requested && null === $stored ) { |
| 549 | return true; |
| 550 | } |
| 551 | if ( null === $requested || null === $stored ) { |
| 552 | return false; |
| 553 | } |
| 554 | if ( is_scalar( $requested ) && is_scalar( $stored ) ) { |
| 555 | // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- numeric/string drift after REST round-trip is expected |
| 556 | return (string) $requested === (string) $stored; |
| 557 | } |
| 558 | return $requested === $stored; |
| 559 | } |
| 560 | |
| 561 | /** |
| 562 | * Build the set of preset slugs that exist after this update, by union-ing |
| 563 | * the slugs already in the stored payload with the slugs the request is |
| 564 | * adding. Used to detect `var:preset|*|<slug>` references that point at |
| 565 | * nothing. |
| 566 | * |
| 567 | * The result is a map keyed by `<kind>|<slug>` (e.g. `font-family|fira-code`) |
| 568 | * because preset namespaces are independent — a color slug doesn't satisfy |
| 569 | * a font-family reference. |
| 570 | * |
| 571 | * @param array $requested Requested payload. |
| 572 | * @param array $stored Stored payload returned by REST. |
| 573 | * @return array<string, bool> |
| 574 | */ |
| 575 | private function collect_known_preset_slugs( array $requested, array $stored ): array { |
| 576 | $slugs = array(); |
| 577 | |
| 578 | // Each collector points at the parent of the preset list. WP wraps the |
| 579 | // list in `{custom, theme, default}` origin buckets when storing user |
| 580 | // updates, so we flatten across all origins to collect every defined |
| 581 | // slug regardless of where it was registered (top-level OR per-block, |
| 582 | // custom OR theme-provided). |
| 583 | $collectors = array( |
| 584 | 'font-family' => array( 'typography', 'fontFamilies' ), |
| 585 | 'font-size' => array( 'typography', 'fontSizes' ), |
| 586 | 'color' => array( 'color', 'palette' ), |
| 587 | ); |
| 588 | |
| 589 | foreach ( array( $requested, $stored ) as $source ) { |
| 590 | $source = $this->coerce_to_array( $source ); |
| 591 | foreach ( $this->iter_per_block_and_global_settings( $source ) as $settings ) { |
| 592 | foreach ( $collectors as $kind => $path ) { |
| 593 | $node = $settings; |
| 594 | foreach ( $path as $segment ) { |
| 595 | $node = is_object( $node ) ? (array) $node : $node; |
| 596 | if ( ! is_array( $node ) || ! isset( $node[ $segment ] ) ) { |
| 597 | $node = null; |
| 598 | break; |
| 599 | } |
| 600 | $node = $node[ $segment ]; |
| 601 | } |
| 602 | $node = $this->flatten_origin_buckets( $node ); |
| 603 | if ( ! is_array( $node ) ) { |
| 604 | continue; |
| 605 | } |
| 606 | foreach ( $node as $item ) { |
| 607 | $item = is_object( $item ) ? (array) $item : $item; |
| 608 | if ( is_array( $item ) && isset( $item['slug'] ) && is_string( $item['slug'] ) ) { |
| 609 | $slugs[ $kind . '|' . $item['slug'] ] = true; |
| 610 | } |
| 611 | } |
| 612 | } |
| 613 | } |
| 614 | } |
| 615 | |
| 616 | return $slugs; |
| 617 | } |
| 618 | |
| 619 | /** |
| 620 | * Unwrap WordPress's `{custom, theme, default}` "origin" buckets into a |
| 621 | * single flat list. When the caller posts a flat preset list (e.g. |
| 622 | * `settings.typography.fontFamilies: [...]`) WP normalises the stored |
| 623 | * shape to `{custom: [...]}`. The diff walker uses flat list semantics |
| 624 | * (`[slug=...]`, `[N]`), so any indexed lookup against a stored payload |
| 625 | * must flatten the buckets first or it will false-negative every item. |
| 626 | * |
| 627 | * Behaviour: |
| 628 | * - List in → list out (passes through). |
| 629 | * - Dict with any of {custom, theme, default} keys → concatenated values. |
| 630 | * - Anything else → returned unchanged. |
| 631 | * |
| 632 | * Coerces stdClass nodes before inspecting them. |
| 633 | * |
| 634 | * @param mixed $node Subtree to flatten. |
| 635 | * @return mixed Flat list when wrapping detected, otherwise $node unchanged. |
| 636 | */ |
| 637 | private function flatten_origin_buckets( $node ) { |
| 638 | if ( is_object( $node ) ) { |
| 639 | $node = (array) $node; |
| 640 | } |
| 641 | if ( ! is_array( $node ) || empty( $node ) ) { |
| 642 | return $node; |
| 643 | } |
| 644 | // Already a list — nothing to unwrap. |
| 645 | if ( array_keys( $node ) === range( 0, count( $node ) - 1 ) ) { |
| 646 | return $node; |
| 647 | } |
| 648 | $origin_keys = array_intersect( array_keys( $node ), array( 'custom', 'theme', 'default' ) ); |
| 649 | if ( empty( $origin_keys ) ) { |
| 650 | return $node; |
| 651 | } |
| 652 | $flat = array(); |
| 653 | foreach ( $origin_keys as $k ) { |
| 654 | $bucket = is_object( $node[ $k ] ) ? (array) $node[ $k ] : $node[ $k ]; |
| 655 | if ( is_array( $bucket ) ) { |
| 656 | foreach ( $bucket as $item ) { |
| 657 | $flat[] = $item; |
| 658 | } |
| 659 | } |
| 660 | } |
| 661 | return $flat; |
| 662 | } |
| 663 | |
| 664 | /** |
| 665 | * Detect a preset reference (`var:preset|<kind>|<slug>`) that will not |
| 666 | * resolve at render time — either because the token is wrong (`font` |
| 667 | * instead of `font-family`) or because the slug isn't registered anywhere. |
| 668 | * |
| 669 | * Only fires on `styles.*` leaves: under `settings.*` a similar-looking |
| 670 | * string would be data, not an applied reference. |
| 671 | * |
| 672 | * @param string $path Dot-path of the leaf. |
| 673 | * @param mixed $value Requested leaf value. |
| 674 | * @param array<string, bool> $preset_slugs Known preset slugs keyed by `<kind>|<slug>`. |
| 675 | * @return string|null Human-readable reason, or null when nothing is wrong. |
| 676 | */ |
| 677 | private function detect_unresolved_preset_reference( string $path, $value, array $preset_slugs ): ?string { |
| 678 | if ( ! is_string( $value ) || ! str_starts_with( $path, 'styles.' ) ) { |
| 679 | return null; |
| 680 | } |
| 681 | |
| 682 | // WordPress accepts two interchangeable preset forms: |
| 683 | // var:preset|<kind>|<slug> |
| 684 | // var(--wp--preset--<kind>--<slug>) |
| 685 | // LLMs reach for the second form when writing CSS-like values, so |
| 686 | // check both. |
| 687 | if ( preg_match( '/^var:preset\|([^|]+)\|(.+)$/', $value, $m ) ) { |
| 688 | $kind = $m[1]; |
| 689 | $slug = $m[2]; |
| 690 | } elseif ( preg_match( '/^var\(--wp--preset--([a-z0-9-]+)--([a-z0-9-]+)\)$/', $value, $m ) ) { |
| 691 | $kind = $m[1]; |
| 692 | $slug = $m[2]; |
| 693 | } else { |
| 694 | return null; |
| 695 | } |
| 696 | |
| 697 | if ( 'font' === $kind ) { |
| 698 | return "Preset reference '{$value}' uses the wrong token — use 'var:preset|font-family|{$slug}' (not 'font'). WordPress will not resolve 'var:preset|font|*' and the stored value will have no visual effect."; |
| 699 | } |
| 700 | |
| 701 | if ( ! isset( $preset_slugs[ $kind . '|' . $slug ] ) ) { |
| 702 | $snippet = $this->build_registration_snippet( $kind, $slug ); |
| 703 | return "Preset reference '{$value}' points at an unknown slug — the value was stored but will not resolve at render time. To fix it, REGISTER the slug AND keep this styles application in the SAME call. Add this to your payload (fill in the placeholder fields), then resend together: {$snippet}"; |
| 704 | } |
| 705 | |
| 706 | return null; |
| 707 | } |
| 708 | |
| 709 | /** |
| 710 | * Build a copy-paste-ready JSON snippet for the registration that an |
| 711 | * unresolved preset reference needs. The caller pastes this into the |
| 712 | * `settings.*` half of the payload, fills in the few placeholder fields |
| 713 | * (display name, CSS family stack, hex colour, size), and resends with |
| 714 | * the original `styles.*` application still attached. |
| 715 | * |
| 716 | * Slug → display name uses title-case of the dash-separated slug |
| 717 | * ("fira-code" → "Fira Code") as a reasonable default the caller can |
| 718 | * override. |
| 719 | * |
| 720 | * @param string $kind Preset kind: font-family | color | font-size | … |
| 721 | * @param string $slug Preset slug. |
| 722 | * @return string Snippet that can be embedded inline in a hint string. |
| 723 | */ |
| 724 | private function build_registration_snippet( string $kind, string $slug ): string { |
| 725 | $display = ucwords( str_replace( array( '-', '_' ), ' ', $slug ) ); |
| 726 | |
| 727 | switch ( $kind ) { |
| 728 | case 'font-family': |
| 729 | return wp_json_encode( |
| 730 | array( |
| 731 | 'settings' => array( |
| 732 | 'typography' => array( |
| 733 | 'fontFamilies' => array( |
| 734 | array( |
| 735 | 'slug' => $slug, |
| 736 | 'name' => $display, |
| 737 | 'fontFamily' => "\"{$display}\", <fallback stack — e.g. monospace or sans-serif>", |
| 738 | ), |
| 739 | ), |
| 740 | ), |
| 741 | ), |
| 742 | ) |
| 743 | ); |
| 744 | case 'color': |
| 745 | return wp_json_encode( |
| 746 | array( |
| 747 | 'settings' => array( |
| 748 | 'color' => array( |
| 749 | 'palette' => array( |
| 750 | array( |
| 751 | 'slug' => $slug, |
| 752 | 'name' => $display, |
| 753 | 'color' => '<#hex value>', |
| 754 | ), |
| 755 | ), |
| 756 | ), |
| 757 | ), |
| 758 | ) |
| 759 | ); |
| 760 | case 'font-size': |
| 761 | return wp_json_encode( |
| 762 | array( |
| 763 | 'settings' => array( |
| 764 | 'typography' => array( |
| 765 | 'fontSizes' => array( |
| 766 | array( |
| 767 | 'slug' => $slug, |
| 768 | 'name' => $display, |
| 769 | 'size' => '<CSS size — e.g. 1rem>', |
| 770 | ), |
| 771 | ), |
| 772 | ), |
| 773 | ), |
| 774 | ) |
| 775 | ); |
| 776 | default: |
| 777 | return wp_json_encode( |
| 778 | array( |
| 779 | 'settings' => array( |
| 780 | $kind => array( |
| 781 | array( |
| 782 | 'slug' => $slug, |
| 783 | 'name' => $display, |
| 784 | ), |
| 785 | ), |
| 786 | ), |
| 787 | ) |
| 788 | ); |
| 789 | } |
| 790 | } |
| 791 | |
| 792 | /** |
| 793 | * Reason text for a leaf whose requested value did not land in storage. |
| 794 | * Kept deliberately small — the caller has already ruled out the preset |
| 795 | * cases and the misplaced-subtree case before reaching this helper. |
| 796 | * |
| 797 | * @param mixed $stored Stored value at the path (or null when absent). |
| 798 | * @return string |
| 799 | */ |
| 800 | private function explain_missing_leaf( $stored ): string { |
| 801 | if ( null === $stored ) { |
| 802 | return 'Path was not written to the stored global styles. Most likely the key is not part of theme.json schema, or it was overridden by a sanitization step.'; |
| 803 | } |
| 804 | |
| 805 | if ( is_scalar( $stored ) ) { |
| 806 | $stored_preview = (string) $stored; |
| 807 | } else { |
| 808 | $encoded = (string) wp_json_encode( $stored ); |
| 809 | $stored_preview = strlen( $encoded ) > 200 ? substr( $encoded, 0, 200 ) . '…' : $encoded; |
| 810 | } |
| 811 | return "Stored value differs from requested. Stored: {$stored_preview}."; |
| 812 | } |
| 813 | |
| 814 | /** |
| 815 | * Shallow object→array coercion for the top level of a REST payload, where |
| 816 | * `WP_REST_Global_Styles_Controller` may emit `stdClass` for empty |
| 817 | * subtrees. Deeper coercion happens inline at each traversal step. |
| 818 | * |
| 819 | * @param mixed $value Value to coerce. |
| 820 | * @return mixed Array when $value is array-like, otherwise $value unchanged. |
| 821 | */ |
| 822 | private function coerce_to_array( $value ) { |
| 823 | if ( is_object( $value ) ) { |
| 824 | return (array) $value; |
| 825 | } |
| 826 | return $value; |
| 827 | } |
| 828 | |
| 829 | /** |
| 830 | * Yield every settings-shaped subtree in a global-styles payload — the |
| 831 | * top-level `settings` and each `settings.blocks.<block>` per-block |
| 832 | * override. Used to collect preset slugs from every location WordPress |
| 833 | * recognises, not just the root. |
| 834 | * |
| 835 | * @param array $payload Coerced top-level payload. |
| 836 | * @return iterable<array> |
| 837 | */ |
| 838 | private function iter_per_block_and_global_settings( array $payload ): iterable { |
| 839 | $settings = $payload['settings'] ?? array(); |
| 840 | $settings = is_object( $settings ) ? (array) $settings : $settings; |
| 841 | if ( ! is_array( $settings ) ) { |
| 842 | return; |
| 843 | } |
| 844 | |
| 845 | yield $settings; |
| 846 | |
| 847 | $blocks = $settings['blocks'] ?? array(); |
| 848 | $blocks = is_object( $blocks ) ? (array) $blocks : $blocks; |
| 849 | if ( ! is_array( $blocks ) ) { |
| 850 | return; |
| 851 | } |
| 852 | |
| 853 | foreach ( $blocks as $block_settings ) { |
| 854 | $block_settings = is_object( $block_settings ) ? (array) $block_settings : $block_settings; |
| 855 | if ( is_array( $block_settings ) ) { |
| 856 | yield $block_settings; |
| 857 | } |
| 858 | } |
| 859 | } |
| 860 | } |