Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
95.50% |
530 / 555 |
|
18.18% |
2 / 11 |
CRAP | |
0.00% |
0 / 1 |
| BlockEditor | |
95.50% |
530 / 555 |
|
18.18% |
2 / 11 |
58 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| register_abilities | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| register_edit_block | |
94.67% |
71 / 75 |
|
0.00% |
0 / 1 |
7.01 | |||
| register_add_section | |
97.89% |
93 / 95 |
|
0.00% |
0 / 1 |
16 | |||
| register_delete_block | |
97.62% |
41 / 42 |
|
0.00% |
0 / 1 |
2 | |||
| register_duplicate | |
89.29% |
75 / 84 |
|
0.00% |
0 / 1 |
9.10 | |||
| register_insert_inner_block | |
96.23% |
51 / 53 |
|
0.00% |
0 / 1 |
5 | |||
| register_move_block | |
93.85% |
61 / 65 |
|
0.00% |
0 / 1 |
10.02 | |||
| register_highlight_block | |
97.62% |
41 / 42 |
|
0.00% |
0 / 1 |
2 | |||
| register_update_block_attrs | |
97.87% |
46 / 47 |
|
0.00% |
0 / 1 |
3 | |||
| register_get_block_markup | |
97.62% |
41 / 42 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Block Editor Abilities |
| 4 | * |
| 5 | * Provides abilities for editing WordPress block content in the site editor. |
| 6 | * These abilities return action data that the client executes via blockEditorService.js. |
| 7 | * |
| 8 | * @package BLU |
| 9 | */ |
| 10 | |
| 11 | declare( strict_types=1 ); |
| 12 | |
| 13 | namespace BLU\Abilities; |
| 14 | |
| 15 | /** |
| 16 | * BlockEditor class |
| 17 | * |
| 18 | * Registers abilities for editing, adding, deleting, and moving blocks. |
| 19 | * Each ability is a single-purpose tool designed for clear AI tool selection. |
| 20 | */ |
| 21 | class BlockEditor { |
| 22 | |
| 23 | /** |
| 24 | * Constructor - registers block editor abilities. |
| 25 | */ |
| 26 | public function __construct() { |
| 27 | $this->register_abilities(); |
| 28 | } |
| 29 | |
| 30 | /** |
| 31 | * Register all block editor abilities. |
| 32 | * |
| 33 | * @return void |
| 34 | */ |
| 35 | private function register_abilities(): void { |
| 36 | $this->register_edit_block(); |
| 37 | $this->register_add_section(); |
| 38 | $this->register_delete_block(); |
| 39 | $this->register_duplicate(); |
| 40 | $this->register_insert_inner_block(); |
| 41 | $this->register_move_block(); |
| 42 | $this->register_get_block_markup(); |
| 43 | $this->register_highlight_block(); |
| 44 | $this->register_update_block_attrs(); |
| 45 | } |
| 46 | |
| 47 | /** |
| 48 | * Register ability to edit block content (rewrite mode only) |
| 49 | * |
| 50 | * Replaces the entire content of a block with new WordPress block markup. |
| 51 | * |
| 52 | * @return void |
| 53 | */ |
| 54 | private function register_edit_block(): void { |
| 55 | // phpcs:disable Generic.Files.LineLength.TooLong -- Tool description includes inline rules for AI context. |
| 56 | $description = <<<'DESC' |
| 57 | LAST RESORT — prefer a cheaper tool first. This tool forces you to re-emit the block's ENTIRE subtree markup (all inner blocks included) and is by far the slowest operation, so pick one of the alternatives below whenever they fit: |
| 58 | - "add another <child> with the same design as these" → blu/duplicate-block (clones an existing sibling, zero markup regeneration). |
| 59 | - "insert a <new child> into this container" (columns, group, row, stack) → blu/insert-inner-block. |
| 60 | - "change color / spacing / padding / alignment / font / any attribute" on any block (top-level or nested) → blu/update-block-attrs with the block's own client_id. |
| 61 | - "add a new top-level section on the page" → blu/add-section. |
| 62 | - "delete / move / reorder" → blu/delete-block or blu/move-block. |
| 63 | |
| 64 | Use edit-block ONLY when the user asks for structural content rewrites that cannot be expressed as a duplicate, insert, attribute patch, add, delete, or move — for example replacing a paragraph's text, swapping an image, or rebuilding a block's inner markup from scratch. |
| 65 | |
| 66 | Replace the entire content of an existing block (and its inner blocks) with new WordPress block markup. The block_content MUST be valid WordPress block markup with proper block comments (<!-- wp:blockname {...} -->...<!-- /wp:blockname -->). Always include all inner blocks if the target block has children. Use the client_id from the block tree context. TEMPLATE PARTS: When editing a core/template-part, provide ONLY the inner blocks markup — do NOT wrap it in <!-- wp:template-part --> comments. You can wrap blocks in core/group, core/stack, or similar container blocks when needed for layout or styling. |
| 67 | |
| 68 | ADDITIONAL RULES: |
| 69 | - CONTENT PRESERVATION (CRITICAL): Copy ALL existing text, link URLs, image sources, and inner blocks from the original markup into your replacement. NEVER substitute existing content with different or generic text. If the button says "Start Creating for Free Today!" and you are changing its color, your output MUST keep that exact text — changing it to "Contact Us", "Button", or anything else is a critical error. For style-only changes (color, font, spacing), modify ONLY the block comment JSON and the corresponding HTML classes/styles — copy everything else character-for-character. |
| 70 | - VALID MARKUP: Every block_content you provide MUST be valid WordPress block markup with proper <!-- wp:name {attrs} --> comments. Never output plain HTML without block comments. |
| 71 | - INNER BLOCKS: When editing a block that has inner blocks, include ALL inner blocks in your replacement markup unless the user specifically asked to remove them. |
| 72 | - CONTAINER BLOCKS: Container blocks (core/group, core/columns, core/column, core/cover, core/row, core/stack, core/buttons) render content using WordPress InnerBlocks. ALL visible content inside them MUST be wrapped in proper block comments. NEVER put raw HTML like <p>, <h2>, <ul>, or <figure> directly inside a container wrapper — it will be silently stripped and the block will appear empty. WRONG: <!-- wp:group --> <div class="wp-block-group"><p>Hello</p></div> <!-- /wp:group -->. RIGHT: <!-- wp:group --> <div class="wp-block-group"><!-- wp:paragraph --> <p>Hello</p> <!-- /wp:paragraph --></div> <!-- /wp:group -->. |
| 73 | - COLORS: ALWAYS use the site's theme palette colors — NEVER invent arbitrary hex colors (like yellow #ffcc00 or blue #0066cc). The available palette slugs are: base, contrast, accent-1, accent-2, accent-3, accent-4, accent-5, accent-6. Apply them via "backgroundColor" and "textColor" attributes (e.g., "backgroundColor":"accent-1", "textColor":"contrast"). Only use custom hex values when the user explicitly requests a specific color. To reference a palette color in the style object use "var:preset|color|<slug>". In inline CSS use var(--wp--preset--color--<slug>). In HTML, use has-<slug>-background-color / has-<slug>-color classes. If the existing markup has an invalid slug (e.g., "backgroundColor":"red"), fix it by replacing with the nearest palette slug or removing the attribute and using the style object with a HEX value: {"style":{"color":{"background":"#ff0000"}}}. This rule applies to EVERY block in your output — scan the ENTIRE block_content before returning it. |
| 74 | - NFD UTILITY CLASSES: Do NOT add new nfd-* classes to blocks. When editing a block that has existing nfd-* classes, PRESERVE all nfd-* classes unless the user specifically asks to change the property they control. If the user asks to change a property controlled by an nfd-* class (e.g., "change the padding"), remove the nfd-* class for that property and apply the styling using WordPress block attributes instead. If the editor context includes an nfd class reference section, use it to understand what each class does. Key rules: NEVER remove nfd-container (controls container width), nfd-theme-* (controls color scheme), nfd-wb-*/nfd-delay-* (controls animations), nfd-bg-effect-* (controls decorative backgrounds), nfd-divider-* (controls section dividers). When replacing an nfd-* spacing/color/typography class, use the resolved CSS value from the reference (not a guess) to set the equivalent WordPress block attribute. Preserve: nfd-bg-surface, nfd-bg-primary, nfd-bg-subtle, nfd-text-faded, nfd-text-contrast, nfd-text-primary, nfd-btn-*, nfd-rounded-*, nfd-shadow-*. |
| 75 | - IMAGE ASPECT RATIO: When the user asks to change an image's aspect ratio, use the "aspectRatio" and "scale" attributes — NEVER set fixed "width"/"height" in pixels. Valid aspect ratios: "1/1", "4/3", "3/4", "3/2", "2/3", "16/9", "9/16". Example: <!-- wp:image {"aspectRatio":"16/9","scale":"cover","sizeSlug":"full"} --> <figure class="wp-block-image size-full"><img src="..." alt="" style="aspect-ratio:16/9;object-fit:cover"/></figure> <!-- /wp:image -->. The inline style on the <img> tag MUST match: style="aspect-ratio:{ratio};object-fit:{scale}". Remove any existing "width" and "height" attributes and "is-resized" class when switching to aspect ratio. |
| 76 | - COVER BLOCK OVERLAY: The cover block overlay color is controlled ONLY through block comment attributes — NEVER add inline styles to the overlay <span>. The <span> must only have classes, no style attribute. For theme palette colors: use "overlayColor":"<slug>" in the block comment and add class has-<slug>-background-color to the span. For custom colors: use "customOverlayColor":"#hex" in the block comment. The span gets NO inline style — WordPress handles it. Overlay opacity is set via "dimRatio" (0-100) in the block comment. The span class reflects it: has-background-dim-{value} has-background-dim. WRONG: style="background-color:rgba(...)" on the span — this causes block validation failure. |
| 77 | - GRADIENTS: To add a gradient background to a block, use the style.color.gradient attribute in the block comment — NEVER put background-image in the inline style. Block comment: {"style":{"color":{"gradient":"linear-gradient(135deg, #a1c4fd 0%, #c2e9fb 100%)"}}}. HTML: style="background:linear-gradient(135deg, #a1c4fd 0%, #c2e9fb 100%)" (use background: not background-image:). Add has-background class. For theme presets use: {"gradient":"vivid-cyan-blue-to-vivid-purple"}. WRONG: {"style":{"elements":{"background":{"backgroundImage":"..."}}}} or style="background-image:linear-gradient(...)". |
| 78 | - FONT SIZE: When changing a block's font size, ALWAYS remove any existing font-size selection first — then apply the new one. Preset slugs and custom values are mutually exclusive; combining them causes the preset to silently win via CSS specificity. CUSTOM size: REMOVE "fontSize" attribute and has-*-font-size class, then set "style":{"typography":{"fontSize":"4.5rem"}} and style="font-size:4.5rem". PRESET size: REMOVE style.typography.fontSize and inline font-size, then set "fontSize":"x-large" and add has-x-large-font-size class. WRONG: {"fontSize":"x-large","style":{"typography":{"fontSize":"4.5rem"}}}. |
| 79 | - ALIGNMENT & CENTERING: The core/group block does NOT support the align attribute for centering — do NOT set "align":"center" on a group. For flex containers (core/columns, core/buttons): use "align":"center" directly. For core/row or core/stack: set "layout":{"type":"flex","justifyContent":"center"}. For content inside a group: set alignment on inner blocks — core/image and core/buttons support "align":"center"; core/heading and core/paragraph use "textAlign":"center". WRONG: <!-- wp:group {"align":"center"} -->. |
| 80 | - TEMPLATE PARTS: NEVER use edit-block on a template part for COLOR, STYLE, or ATTRIBUTE changes — it will lose blocks like the site-logo. Use blu/update-block-attrs on the SPECIFIC inner block instead. Only use edit-block on a template part to REPLACE ALL content with a completely new design. When ADDING content to a template part, use blu/add-section with before/after_client_id pointing INSIDE the template part. |
| 81 | - IMAGES: When replacing an image already on the page, rewrite its <img src="…"> to use a __IMG_1__ (or __IMG_2__, …) placeholder and pass a descriptive prompt per placeholder in image_prompts (preferred — client generates and substitutes). Use image_urls only when you already have resolved URLs. NEVER embed a full image URL directly in multi-block block_content. |
| 82 | DESC; |
| 83 | // phpcs:enable Generic.Files.LineLength.TooLong |
| 84 | |
| 85 | blu_register_ability( |
| 86 | 'blu/edit-block', |
| 87 | array( |
| 88 | 'label' => 'Edit Block Content', |
| 89 | 'description' => $description, |
| 90 | 'category' => 'blu-mcp', |
| 91 | 'input_schema' => array( |
| 92 | 'type' => 'object', |
| 93 | 'properties' => array( |
| 94 | 'client_id' => array( |
| 95 | 'type' => 'string', |
| 96 | 'description' => 'The clientId of the block to edit, from the block tree context', |
| 97 | ), |
| 98 | 'block_content' => array( |
| 99 | 'type' => 'string', |
| 100 | 'description' => 'Complete WordPress block markup with block comments. Must include <!-- wp:blockname {...} --> opening and <!-- /wp:blockname --> closing comments. Include all inner blocks if the target has children.', |
| 101 | ), |
| 102 | 'image_prompts' => array( |
| 103 | 'type' => 'array', |
| 104 | 'items' => array( |
| 105 | 'oneOf' => array( |
| 106 | array( 'type' => 'string' ), |
| 107 | array( |
| 108 | 'type' => 'object', |
| 109 | 'properties' => array( |
| 110 | 'prompt' => array( 'type' => 'string' ), |
| 111 | 'orientation' => array( 'type' => 'string' ), |
| 112 | 'width' => array( 'type' => 'integer' ), |
| 113 | 'height' => array( 'type' => 'integer' ), |
| 114 | ), |
| 115 | 'required' => array( 'prompt' ), |
| 116 | ), |
| 117 | ), |
| 118 | ), |
| 119 | 'description' => 'Preferred image parameter. One entry per __IMG_N__ placeholder in block_content (in order). Each entry is either a string prompt or {prompt, orientation?, width?, height?}. The client calls blu/generate-image per entry and substitutes each placeholder with the returned URL.', |
| 120 | ), |
| 121 | 'image_urls' => array( |
| 122 | 'type' => 'array', |
| 123 | 'items' => array( 'type' => 'string' ), |
| 124 | 'description' => 'Fallback path when resolved image URLs are already available. Ignored if image_prompts is provided. Replaces __IMG_1__, __IMG_2__, … in order.', |
| 125 | ), |
| 126 | ), |
| 127 | 'required' => array( 'client_id', 'block_content' ), |
| 128 | ), |
| 129 | 'execute_callback' => function ( $input ) { |
| 130 | // Validate required fields |
| 131 | if ( empty( $input['client_id'] ) ) { |
| 132 | return blu_prepare_ability_response( 400, array( 'message' => 'client_id is required' ) ); |
| 133 | } |
| 134 | if ( empty( $input['block_content'] ) ) { |
| 135 | return blu_prepare_ability_response( 400, array( 'message' => 'block_content is required' ) ); |
| 136 | } |
| 137 | |
| 138 | // Return action data for client-side execution |
| 139 | $response_data = array( |
| 140 | 'action' => 'edit_block', |
| 141 | 'client_id' => sanitize_text_field( $input['client_id'] ), |
| 142 | 'block_content' => $input['block_content'], // Don't sanitize - it's block HTML |
| 143 | 'message' => 'Block edit ready for execution', |
| 144 | ); |
| 145 | |
| 146 | // Forward image inputs to the client for placeholder substitution. |
| 147 | if ( ! empty( $input['image_prompts'] ) && is_array( $input['image_prompts'] ) ) { |
| 148 | $response_data['image_prompts'] = $input['image_prompts']; |
| 149 | } |
| 150 | if ( ! empty( $input['image_urls'] ) && is_array( $input['image_urls'] ) ) { |
| 151 | $response_data['image_urls'] = array_map( 'esc_url_raw', $input['image_urls'] ); |
| 152 | } |
| 153 | |
| 154 | return blu_prepare_ability_response( 200, $response_data ); |
| 155 | }, |
| 156 | 'permission_callback' => fn() => current_user_can( 'edit_posts' ), |
| 157 | 'meta' => array( |
| 158 | 'annotations' => array( |
| 159 | 'readonly' => false, |
| 160 | 'destructive' => true, |
| 161 | 'idempotent' => false, |
| 162 | ), |
| 163 | 'mcp' => array( |
| 164 | 'public' => true, |
| 165 | 'type' => 'tool', |
| 166 | ), |
| 167 | ), |
| 168 | ) |
| 169 | ); |
| 170 | } |
| 171 | |
| 172 | /** |
| 173 | * Register ability to add a new section |
| 174 | * |
| 175 | * Inserts new block(s) at a specific position in the page. |
| 176 | * |
| 177 | * @return void |
| 178 | */ |
| 179 | private function register_add_section(): void { |
| 180 | // phpcs:disable Generic.Files.LineLength.TooLong -- Tool description includes inline rules for AI context. |
| 181 | $description = <<<'DESC' |
| 182 | Insert new block content at a specific position in the page. Use after_client_id to insert after a block, before_client_id to insert before a block, or set after_client_id to null (and omit before_client_id) to insert at the very top of the page. |
| 183 | |
| 184 | ADDITIONAL RULES: |
| 185 | - VALID MARKUP: Every block_content you provide MUST be valid WordPress block markup with proper <!-- wp:name {attrs} --> comments. Never output plain HTML without block comments. |
| 186 | - CONTAINER BLOCKS: Container blocks (core/group, core/columns, core/column, core/cover, core/row, core/stack, core/buttons) render content using WordPress InnerBlocks. ALL visible content inside them MUST be wrapped in proper block comments. NEVER put raw HTML like <p>, <h2>, <ul>, or <figure> directly inside a container wrapper — it will be silently stripped and the block will appear empty. WRONG: <!-- wp:group --> <div class="wp-block-group"><p>Hello</p></div> <!-- /wp:group -->. RIGHT: <!-- wp:group --> <div class="wp-block-group"><!-- wp:paragraph --> <p>Hello</p> <!-- /wp:paragraph --></div> <!-- /wp:group -->. |
| 187 | - ADDING SECTIONS: You can insert content before or after ANY block at any nesting depth — not just top-level blocks. Use after_client_id to insert after a specific block, or before_client_id to insert before it. When the user does NOT specify a position, insert at the top level of the page (use after_client_id of the last top-level block in the tree, or null for the very top). |
| 188 | - TEMPLATE PARTS: When adding content to a template part (e.g., adding a top bar above a header, or a banner inside a footer), use blu/add-section with before_client_id or after_client_id pointing to a block INSIDE the template part. This preserves all existing blocks and their layout. Do NOT rewrite the entire template part with blu/edit-block just to add new content — rewriting risks losing layout attributes (flex, gap, etc.) and breaking the design. Only use blu/edit-block on a template part when replacing ALL of its content with a completely different design. |
| 189 | - COLORS: Use the site's theme palette slugs (base, contrast, accent-1..6) via "backgroundColor"/"textColor". Only use custom hex when the user explicitly requests a specific color. In the style object use "var:preset|color|<slug>". In inline CSS use var(--wp--preset--color--<slug>). In HTML use has-<slug>-background-color / has-<slug>-color classes. |
| 190 | - IMAGES: Use __IMG_1__, __IMG_2__, … as URL placeholders in block_content and provide a descriptive prompt for each placeholder in image_prompts (preferred — the client generates and substitutes URLs automatically). Fall back to image_urls only when you already have resolved URLs on hand. NEVER embed full image URLs directly in multi-block block_content — it will be truncated. |
| 191 | DESC; |
| 192 | // phpcs:enable Generic.Files.LineLength.TooLong |
| 193 | |
| 194 | blu_register_ability( |
| 195 | 'blu/add-section', |
| 196 | array( |
| 197 | 'label' => 'Add New Section', |
| 198 | 'description' => $description, |
| 199 | 'category' => 'blu-mcp', |
| 200 | 'input_schema' => array( |
| 201 | 'type' => 'object', |
| 202 | 'properties' => array( |
| 203 | 'after_client_id' => array( |
| 204 | 'type' => array( 'string', 'null' ), |
| 205 | 'description' => 'The clientId of the block to insert AFTER. Use null to insert at the very top of the page. Mutually exclusive with before_client_id.', |
| 206 | ), |
| 207 | 'before_client_id' => array( |
| 208 | 'type' => 'string', |
| 209 | 'description' => 'The clientId of the block to insert BEFORE. Use this when adding content above the first block inside a template part or at the start of a section. Mutually exclusive with after_client_id.', |
| 210 | ), |
| 211 | 'block_content' => array( |
| 212 | 'type' => 'string', |
| 213 | 'description' => 'Complete WordPress block markup for the new section.', |
| 214 | ), |
| 215 | 'image_prompts' => array( |
| 216 | 'type' => 'array', |
| 217 | 'items' => array( |
| 218 | 'oneOf' => array( |
| 219 | array( 'type' => 'string' ), |
| 220 | array( |
| 221 | 'type' => 'object', |
| 222 | 'properties' => array( |
| 223 | 'prompt' => array( 'type' => 'string' ), |
| 224 | 'orientation' => array( 'type' => 'string' ), |
| 225 | 'width' => array( 'type' => 'integer' ), |
| 226 | 'height' => array( 'type' => 'integer' ), |
| 227 | ), |
| 228 | 'required' => array( 'prompt' ), |
| 229 | ), |
| 230 | ), |
| 231 | ), |
| 232 | 'description' => 'Preferred image parameter. One entry per __IMG_N__ placeholder in block_content (in order). Each entry is either a string prompt ("A bright cafe interior, wide angle") or an object {prompt, orientation?, width?, height?}. The client calls blu/generate-image per entry and substitutes each placeholder with the returned URL. The count must match the number of unique __IMG_N__ placeholders.', |
| 233 | ), |
| 234 | 'image_urls' => array( |
| 235 | 'type' => 'array', |
| 236 | 'items' => array( 'type' => 'string' ), |
| 237 | 'description' => 'Fallback path for when you already have resolved image URLs. Ignored if image_prompts is provided. Replaces __IMG_1__, __IMG_2__, … in order.', |
| 238 | ), |
| 239 | ), |
| 240 | 'required' => array( 'block_content' ), |
| 241 | ), |
| 242 | 'execute_callback' => function ( $input ) { |
| 243 | // Validate: block_content is required |
| 244 | if ( empty( $input['block_content'] ) ) { |
| 245 | return blu_prepare_ability_response( 400, array( 'message' => 'block_content is required' ) ); |
| 246 | } |
| 247 | |
| 248 | // Validate: after_client_id and before_client_id are mutually exclusive. |
| 249 | // Only a non-empty string counts as "set" — explicit null on after_client_id |
| 250 | // is the documented way to request "top of page" and must not conflict. |
| 251 | $has_after_param = ! empty( $input['after_client_id'] ) && is_string( $input['after_client_id'] ); |
| 252 | $has_before_param = ! empty( $input['before_client_id'] ) && is_string( $input['before_client_id'] ); |
| 253 | if ( $has_after_param && $has_before_param ) { |
| 254 | return blu_prepare_ability_response( |
| 255 | 400, |
| 256 | array( |
| 257 | 'message' => 'after_client_id and before_client_id are mutually exclusive. Provide only one.', |
| 258 | ) |
| 259 | ); |
| 260 | } |
| 261 | |
| 262 | // after_client_id can be null (top of page) or a string |
| 263 | $after_client_id = isset( $input['after_client_id'] ) && '' !== $input['after_client_id'] |
| 264 | ? sanitize_text_field( $input['after_client_id'] ) |
| 265 | : null; |
| 266 | |
| 267 | // before_client_id is used to insert BEFORE a given block |
| 268 | $before_client_id = isset( $input['before_client_id'] ) && '' !== $input['before_client_id'] |
| 269 | ? sanitize_text_field( $input['before_client_id'] ) |
| 270 | : null; |
| 271 | |
| 272 | // Return action data for client-side execution |
| 273 | $response_data = array( |
| 274 | 'action' => 'add_section', |
| 275 | 'block_content' => $input['block_content'], |
| 276 | 'message' => 'Section add ready for execution', |
| 277 | ); |
| 278 | |
| 279 | // Forward positional information based on which parameter is used |
| 280 | if ( null !== $after_client_id ) { |
| 281 | $response_data['after_client_id'] = $after_client_id; |
| 282 | } |
| 283 | if ( null !== $before_client_id ) { |
| 284 | $response_data['before_client_id'] = $before_client_id; |
| 285 | } |
| 286 | |
| 287 | // Forward image inputs to the client for placeholder substitution. |
| 288 | // image_prompts is the preferred path (client generates via blu/generate-image); |
| 289 | // image_urls is a fallback when the caller already has URLs. |
| 290 | if ( ! empty( $input['image_prompts'] ) && is_array( $input['image_prompts'] ) ) { |
| 291 | $response_data['image_prompts'] = $input['image_prompts']; |
| 292 | } |
| 293 | if ( ! empty( $input['image_urls'] ) && is_array( $input['image_urls'] ) ) { |
| 294 | $response_data['image_urls'] = array_map( 'esc_url_raw', $input['image_urls'] ); |
| 295 | } |
| 296 | |
| 297 | return blu_prepare_ability_response( 200, $response_data ); |
| 298 | }, |
| 299 | 'permission_callback' => fn() => current_user_can( 'edit_posts' ), |
| 300 | 'meta' => array( |
| 301 | 'annotations' => array( |
| 302 | 'readonly' => false, |
| 303 | 'destructive' => false, |
| 304 | 'idempotent' => false, |
| 305 | ), |
| 306 | 'mcp' => array( |
| 307 | 'public' => true, |
| 308 | 'type' => 'tool', |
| 309 | ), |
| 310 | ), |
| 311 | ) |
| 312 | ); |
| 313 | } |
| 314 | |
| 315 | /** |
| 316 | * Register ability to delete a block |
| 317 | * |
| 318 | * Removes a block and all its inner blocks from the page. |
| 319 | * |
| 320 | * @return void |
| 321 | */ |
| 322 | private function register_delete_block(): void { |
| 323 | blu_register_ability( |
| 324 | 'blu/delete-block', |
| 325 | array( |
| 326 | 'label' => 'Delete Block', |
| 327 | 'description' => 'Remove a block and ALL of its inner blocks from the page. Use this when the user asks to remove, delete, or get rid of a section or block. This action is irreversible without undo. Only delete the specific block the user refers to — do not delete parent blocks unless explicitly asked.', |
| 328 | 'category' => 'blu-mcp', |
| 329 | 'input_schema' => array( |
| 330 | 'type' => 'object', |
| 331 | 'properties' => array( |
| 332 | 'client_id' => array( |
| 333 | 'type' => 'string', |
| 334 | 'description' => 'The clientId of the block to delete, from the block tree context', |
| 335 | ), |
| 336 | ), |
| 337 | 'required' => array( 'client_id' ), |
| 338 | ), |
| 339 | 'execute_callback' => function ( $input ) { |
| 340 | // Validate required fields |
| 341 | if ( empty( $input['client_id'] ) ) { |
| 342 | return blu_prepare_ability_response( 400, array( 'message' => 'client_id is required' ) ); |
| 343 | } |
| 344 | |
| 345 | // Return action data for client-side execution |
| 346 | return blu_prepare_ability_response( |
| 347 | 200, |
| 348 | array( |
| 349 | 'action' => 'delete_block', |
| 350 | 'client_id' => sanitize_text_field( $input['client_id'] ), |
| 351 | 'message' => 'Block delete ready for execution', |
| 352 | ) |
| 353 | ); |
| 354 | }, |
| 355 | 'permission_callback' => fn() => current_user_can( 'edit_posts' ), |
| 356 | 'meta' => array( |
| 357 | 'annotations' => array( |
| 358 | 'readonly' => false, |
| 359 | 'destructive' => true, |
| 360 | 'idempotent' => false, |
| 361 | ), |
| 362 | 'mcp' => array( |
| 363 | 'public' => true, |
| 364 | 'type' => 'tool', |
| 365 | ), |
| 366 | ), |
| 367 | ) |
| 368 | ); |
| 369 | } |
| 370 | |
| 371 | /** |
| 372 | * Register the unified blu/duplicate-block ability. |
| 373 | * |
| 374 | * Dual-mode: caller passes EITHER `client_id` (explicit target) OR `kind` |
| 375 | * (intent-based; client resolves to a concrete block via the block lexicon |
| 376 | * and target resolver). Callers should use `kind` whenever the user phrasing |
| 377 | * is "another X" / "one more X", and `client_id` only when the user points |
| 378 | * at a specific block with "this" / "that". |
| 379 | * |
| 380 | * @return void |
| 381 | */ |
| 382 | private function register_duplicate(): void { |
| 383 | $description = <<<'DESC' |
| 384 | Duplicate a block with its exact existing design. Much faster than edit-block — no markup is regenerated. |
| 385 | |
| 386 | TWO CALLING MODES. Pick based on how the user phrased the request: |
| 387 | |
| 388 | 1. Intent mode (PREFERRED when the user says "another X", "one more X", "add a <kind>" where <kind> matches an existing block on the page): |
| 389 | Pass { kind: "<word>" }. The system finds the right block deterministically. |
| 390 | - "add another column" → { kind: "column" } |
| 391 | - "add another card" → { kind: "card" } |
| 392 | - "add another button" → { kind: "button" } |
| 393 | - "add another menu item" → { kind: "menu-item" } |
| 394 | - "add one more testimonial"→ { kind: "testimonial" } |
| 395 | You may optionally add `scope` (a clientId bounding the search, e.g. a specific section) and `position` ("last" | "first" | integer; default "last"). |
| 396 | |
| 397 | 2. Explicit mode (only when the user points at a specific block — "duplicate THIS", "duplicate that card", or the selection IS exactly what they want another of): |
| 398 | Pass { client_id: "<UUID>" } — copy the UUID EXACTLY from the block tree's `id:<UUID>` field. Never invent placeholder strings. |
| 399 | |
| 400 | Rule of thumb: if you can name what the user said ("column", "card", "button", …), use Intent mode. Only use Explicit mode when the user is clearly referring to one specific block. |
| 401 | |
| 402 | Known kinds: column, button, buttons, image, heading, paragraph, list, list-item, menu-item, card, testimonial, team-member, pricing-tier, faq-item, section, row. Common aliases (col, cols, btn, cta, img, text, p, cards, testimonials, reviews, member, plan, tier, faq, sections) are normalized automatically. If you're unsure which kind a user's word maps to, pass it as-is — the resolver returns a list of known kinds if it doesn't match. |
| 403 | |
| 404 | The clone is inserted immediately after the resolved block as a sibling. |
| 405 | DESC; |
| 406 | |
| 407 | blu_register_ability( |
| 408 | 'blu/duplicate-block', |
| 409 | array( |
| 410 | 'label' => 'Duplicate Block', |
| 411 | 'description' => $description, |
| 412 | 'category' => 'blu-mcp', |
| 413 | 'input_schema' => array( |
| 414 | 'type' => 'object', |
| 415 | 'properties' => array( |
| 416 | 'client_id' => array( |
| 417 | 'type' => 'string', |
| 418 | 'description' => 'Explicit mode: the exact clientId (UUID) of the block to duplicate, copied from the block tree. Use only when the user points at a specific block.', |
| 419 | 'pattern' => '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', |
| 420 | ), |
| 421 | 'kind' => array( |
| 422 | 'type' => 'string', |
| 423 | 'description' => 'Intent mode: the user-facing word for what they want another of ("column", "card", "button", "menu-item", "testimonial", …). Preferred over client_id for "another X" requests.', |
| 424 | ), |
| 425 | 'scope' => array( |
| 426 | 'type' => 'string', |
| 427 | 'description' => 'Intent mode (optional): clientId bounding the search — usually a section/container. Defaults to the selected block\'s ancestors.', |
| 428 | 'pattern' => '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', |
| 429 | ), |
| 430 | 'position' => array( |
| 431 | 'description' => 'Intent mode (optional): which matching sibling to clone. "last" (default), "first", or a 0-based integer index.', |
| 432 | 'oneOf' => array( |
| 433 | array( |
| 434 | 'type' => 'string', |
| 435 | 'enum' => array( 'last', 'first' ), |
| 436 | ), |
| 437 | array( 'type' => 'integer' ), |
| 438 | ), |
| 439 | ), |
| 440 | ), |
| 441 | 'additionalProperties' => false, |
| 442 | ), |
| 443 | 'execute_callback' => function ( $input ) { |
| 444 | $has_client_id = ! empty( $input['client_id'] ); |
| 445 | $has_kind = ! empty( $input['kind'] ); |
| 446 | |
| 447 | if ( ! $has_client_id && ! $has_kind ) { |
| 448 | return blu_prepare_ability_response( |
| 449 | 400, |
| 450 | array( |
| 451 | 'message' => 'Pass EITHER { kind: "<word>" } (intent mode, preferred for "another X") OR { client_id: "<UUID>" } (explicit mode). Neither was supplied.', |
| 452 | ) |
| 453 | ); |
| 454 | } |
| 455 | |
| 456 | $response = array( |
| 457 | 'action' => 'duplicate', |
| 458 | 'message' => 'Block duplicate ready for execution', |
| 459 | ); |
| 460 | |
| 461 | if ( $has_client_id ) { |
| 462 | if ( ! preg_match( '/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/', $input['client_id'] ) ) { |
| 463 | return blu_prepare_ability_response( |
| 464 | 400, |
| 465 | array( |
| 466 | 'message' => sprintf( |
| 467 | 'client_id must be a real UUID from the block tree. Got: "%s". Either switch to intent mode with { kind: "..." } or re-read the block tree and copy the UUID from id:<UUID>.', |
| 468 | $input['client_id'] |
| 469 | ), |
| 470 | ) |
| 471 | ); |
| 472 | } |
| 473 | $response['client_id'] = sanitize_text_field( $input['client_id'] ); |
| 474 | } |
| 475 | |
| 476 | if ( $has_kind ) { |
| 477 | $response['kind'] = sanitize_text_field( $input['kind'] ); |
| 478 | if ( ! empty( $input['scope'] ) ) { |
| 479 | $response['scope'] = sanitize_text_field( $input['scope'] ); |
| 480 | } |
| 481 | if ( isset( $input['position'] ) ) { |
| 482 | $response['position'] = is_int( $input['position'] ) ? $input['position'] : sanitize_text_field( (string) $input['position'] ); |
| 483 | } |
| 484 | } |
| 485 | |
| 486 | return blu_prepare_ability_response( 200, $response ); |
| 487 | }, |
| 488 | 'permission_callback' => fn() => current_user_can( 'edit_posts' ), |
| 489 | 'meta' => array( |
| 490 | 'annotations' => array( |
| 491 | 'readonly' => false, |
| 492 | 'destructive' => false, |
| 493 | 'idempotent' => false, |
| 494 | ), |
| 495 | 'mcp' => array( |
| 496 | 'public' => true, |
| 497 | 'type' => 'tool', |
| 498 | ), |
| 499 | ), |
| 500 | ) |
| 501 | ); |
| 502 | } |
| 503 | |
| 504 | /** |
| 505 | * Register ability to insert a new block as a child of an existing container. |
| 506 | * |
| 507 | * Use when the user wants to add a NEW child to a container (core/columns, |
| 508 | * core/group, core/row, core/stack, core/buttons, etc.) without rewriting the |
| 509 | * whole parent. Only the new child's markup is emitted — the parent and its |
| 510 | * other children are untouched. |
| 511 | * |
| 512 | * @return void |
| 513 | */ |
| 514 | private function register_insert_inner_block(): void { |
| 515 | blu_register_ability( |
| 516 | 'blu/insert-inner-block', |
| 517 | array( |
| 518 | 'label' => 'Insert Inner Block', |
| 519 | 'description' => 'Insert a new block as a child of an existing container block (core/columns, core/group, core/row, core/stack, core/buttons, etc.). Use this when the user asks to insert a NEW item inside a container whose design does NOT already exist on the page — e.g. "add a heading at the top of this group", "insert a button in this buttons row". If a sibling with the same design already exists, prefer blu/duplicate-block instead. Only emit the new child block_content; the parent and its existing children are not touched. Provide parent_client_id (the container) and the new block_content. Optional: index (0-based position; omit to append at the end).', |
| 520 | 'category' => 'blu-mcp', |
| 521 | 'input_schema' => array( |
| 522 | 'type' => 'object', |
| 523 | 'properties' => array( |
| 524 | 'parent_client_id' => array( |
| 525 | 'type' => 'string', |
| 526 | 'description' => 'The clientId of the CONTAINER block that will receive the new child (from the block tree context).', |
| 527 | ), |
| 528 | 'block_content' => array( |
| 529 | 'type' => 'string', |
| 530 | 'description' => 'Valid WordPress block markup for the new child block. Must include proper <!-- wp:blockname {...} --> comments. Only the new child — do NOT re-emit the parent.', |
| 531 | ), |
| 532 | 'index' => array( |
| 533 | 'type' => 'integer', |
| 534 | 'description' => 'Optional 0-based insert position within the parent. Omit to append at the end.', |
| 535 | ), |
| 536 | ), |
| 537 | 'required' => array( 'parent_client_id', 'block_content' ), |
| 538 | ), |
| 539 | 'execute_callback' => function ( $input ) { |
| 540 | if ( empty( $input['parent_client_id'] ) ) { |
| 541 | return blu_prepare_ability_response( 400, array( 'message' => 'parent_client_id is required' ) ); |
| 542 | } |
| 543 | if ( empty( $input['block_content'] ) ) { |
| 544 | return blu_prepare_ability_response( 400, array( 'message' => 'block_content is required' ) ); |
| 545 | } |
| 546 | $response_data = array( |
| 547 | 'action' => 'insert_inner_block', |
| 548 | 'parent_client_id' => sanitize_text_field( $input['parent_client_id'] ), |
| 549 | 'block_content' => $input['block_content'], |
| 550 | 'message' => 'Inner block insert ready for execution', |
| 551 | ); |
| 552 | if ( isset( $input['index'] ) && is_int( $input['index'] ) ) { |
| 553 | $response_data['index'] = $input['index']; |
| 554 | } |
| 555 | return blu_prepare_ability_response( 200, $response_data ); |
| 556 | }, |
| 557 | 'permission_callback' => fn() => current_user_can( 'edit_posts' ), |
| 558 | 'meta' => array( |
| 559 | 'annotations' => array( |
| 560 | 'readonly' => false, |
| 561 | 'destructive' => false, |
| 562 | 'idempotent' => false, |
| 563 | ), |
| 564 | 'mcp' => array( |
| 565 | 'public' => true, |
| 566 | 'type' => 'tool', |
| 567 | ), |
| 568 | ), |
| 569 | ) |
| 570 | ); |
| 571 | } |
| 572 | |
| 573 | /** |
| 574 | * Register ability to move a block |
| 575 | * |
| 576 | * Moves a block (with all inner blocks) to a new position. |
| 577 | * Supports sibling mode (before/after another block) and child mode (into a container block). |
| 578 | * |
| 579 | * @return void |
| 580 | */ |
| 581 | private function register_move_block(): void { |
| 582 | blu_register_ability( |
| 583 | 'blu/move-block', |
| 584 | array( |
| 585 | 'label' => 'Move Block', |
| 586 | 'description' => 'Move a block (with all its inner blocks) to a new position. Two modes: (1) Sibling mode: provide target_client_id + position to place before/after another block. (2) Child mode: provide as_child_of to move the block inside a container block (e.g. move a column into a columns block). Use this when the user asks to reorder sections, move content up/down, rearrange page layout, or restructure blocks into new containers.', |
| 587 | 'category' => 'blu-mcp', |
| 588 | 'input_schema' => array( |
| 589 | 'type' => 'object', |
| 590 | 'properties' => array( |
| 591 | 'client_id' => array( |
| 592 | 'type' => 'string', |
| 593 | 'description' => 'The clientId of the block to move, from the block tree context', |
| 594 | ), |
| 595 | 'target_client_id' => array( |
| 596 | 'type' => 'string', |
| 597 | 'description' => 'Sibling mode: the clientId of the block to position relative to. Required when using position.', |
| 598 | ), |
| 599 | 'position' => array( |
| 600 | 'type' => 'string', |
| 601 | 'enum' => array( 'before', 'after' ), |
| 602 | 'description' => 'Sibling mode: where to place the block relative to the target: "before" or "after"', |
| 603 | ), |
| 604 | 'as_child_of' => array( |
| 605 | 'type' => 'string', |
| 606 | 'description' => 'Child mode: the clientId of the container block to move into (e.g. a core/columns block). The block is appended as the last child. Use this to restructure layouts, e.g. moving columns between different columns blocks.', |
| 607 | ), |
| 608 | ), |
| 609 | 'required' => array( 'client_id' ), |
| 610 | ), |
| 611 | 'execute_callback' => function ( $input ) { |
| 612 | // Validate required fields |
| 613 | if ( empty( $input['client_id'] ) ) { |
| 614 | return blu_prepare_ability_response( 400, array( 'message' => 'client_id is required' ) ); |
| 615 | } |
| 616 | |
| 617 | $has_sibling_mode = ! empty( $input['target_client_id'] ) && ! empty( $input['position'] ); |
| 618 | $has_child_mode = ! empty( $input['as_child_of'] ); |
| 619 | |
| 620 | if ( $has_sibling_mode && $has_child_mode ) { |
| 621 | return blu_prepare_ability_response( 400, array( 'message' => 'Cannot use both sibling mode and child mode' ) ); |
| 622 | } |
| 623 | |
| 624 | if ( ! $has_sibling_mode && ! $has_child_mode ) { |
| 625 | return blu_prepare_ability_response( 400, array( 'message' => 'Provide either (target_client_id + position) for sibling mode or as_child_of for child mode' ) ); |
| 626 | } |
| 627 | |
| 628 | if ( $has_sibling_mode && ! in_array( $input['position'], array( 'before', 'after' ), true ) ) { |
| 629 | return blu_prepare_ability_response( 400, array( 'message' => 'position must be "before" or "after"' ) ); |
| 630 | } |
| 631 | |
| 632 | $response_data = array( |
| 633 | 'action' => 'move_block', |
| 634 | 'client_id' => sanitize_text_field( $input['client_id'] ), |
| 635 | 'message' => 'Block move ready for execution', |
| 636 | ); |
| 637 | |
| 638 | if ( $has_child_mode ) { |
| 639 | $response_data['as_child_of'] = sanitize_text_field( $input['as_child_of'] ); |
| 640 | } else { |
| 641 | $response_data['target_client_id'] = sanitize_text_field( $input['target_client_id'] ); |
| 642 | $response_data['position'] = sanitize_text_field( $input['position'] ); |
| 643 | } |
| 644 | |
| 645 | // Return action data for client-side execution |
| 646 | return blu_prepare_ability_response( 200, $response_data ); |
| 647 | }, |
| 648 | 'permission_callback' => fn() => current_user_can( 'edit_posts' ), |
| 649 | 'meta' => array( |
| 650 | 'annotations' => array( |
| 651 | 'readonly' => false, |
| 652 | 'destructive' => false, |
| 653 | 'idempotent' => false, |
| 654 | ), |
| 655 | 'mcp' => array( |
| 656 | 'public' => true, |
| 657 | 'type' => 'tool', |
| 658 | ), |
| 659 | ), |
| 660 | ) |
| 661 | ); |
| 662 | } |
| 663 | |
| 664 | /** |
| 665 | * Register ability to highlight a block |
| 666 | * |
| 667 | * Selects and flashes a block in the editor to draw the user's attention. |
| 668 | * This is a read-only, non-destructive tool — no undo needed. |
| 669 | * |
| 670 | * @return void |
| 671 | */ |
| 672 | private function register_highlight_block(): void { |
| 673 | // phpcs:disable Generic.Files.LineLength.TooLong -- Tool description includes inline rules for AI context. |
| 674 | $description = <<<'DESC' |
| 675 | Highlight and scroll to a specific block in the editor. Use this when the user asks where a block is, asks you to point to something, or when you want to draw attention to a specific block while explaining something. The block will be selected and briefly flash to draw the user's eye. |
| 676 | |
| 677 | ADDITIONAL RULES: |
| 678 | - HIGHLIGHTING: When the user asks where a block is, what a block looks like, or asks you to point to something, use blu/highlight-block to select and flash the block. This scrolls it into view and adds a brief visual pulse. Do NOT use this on every tool call — only when the user is asking about location or you need to draw attention to a specific block. |
| 679 | DESC; |
| 680 | // phpcs:enable Generic.Files.LineLength.TooLong |
| 681 | |
| 682 | blu_register_ability( |
| 683 | 'blu/highlight-block', |
| 684 | array( |
| 685 | 'label' => 'Highlight Block', |
| 686 | 'description' => $description, |
| 687 | 'category' => 'blu-mcp', |
| 688 | 'input_schema' => array( |
| 689 | 'type' => 'object', |
| 690 | 'properties' => array( |
| 691 | 'client_id' => array( |
| 692 | 'type' => 'string', |
| 693 | 'description' => 'The clientId of the block to highlight, from the block tree context', |
| 694 | ), |
| 695 | ), |
| 696 | 'required' => array( 'client_id' ), |
| 697 | ), |
| 698 | 'execute_callback' => function ( $input ) { |
| 699 | if ( empty( $input['client_id'] ) ) { |
| 700 | return blu_prepare_ability_response( 400, array( 'message' => 'client_id is required' ) ); |
| 701 | } |
| 702 | |
| 703 | return blu_prepare_ability_response( |
| 704 | 200, |
| 705 | array( |
| 706 | 'action' => 'highlight_block', |
| 707 | 'client_id' => sanitize_text_field( $input['client_id'] ), |
| 708 | 'message' => 'Block highlight ready for execution', |
| 709 | ) |
| 710 | ); |
| 711 | }, |
| 712 | 'permission_callback' => fn() => current_user_can( 'edit_posts' ), |
| 713 | 'meta' => array( |
| 714 | 'annotations' => array( |
| 715 | 'readonly' => true, |
| 716 | 'destructive' => false, |
| 717 | 'idempotent' => true, |
| 718 | ), |
| 719 | 'mcp' => array( |
| 720 | 'public' => true, |
| 721 | 'type' => 'tool', |
| 722 | ), |
| 723 | ), |
| 724 | ) |
| 725 | ); |
| 726 | } |
| 727 | |
| 728 | /** |
| 729 | * Register ability to update block attributes without replacing markup. |
| 730 | * |
| 731 | * Deep-merges new attributes into the existing block — no need to read |
| 732 | * or rewrite the full markup. Use for colors, font sizes, alignment, |
| 733 | * spacing, overlays, gradients, layout, and any block comment JSON change. |
| 734 | * |
| 735 | * @return void |
| 736 | */ |
| 737 | private function register_update_block_attrs(): void { |
| 738 | $description = <<<'DESC' |
| 739 | Patch attributes on an existing block — deep-merged into whatever is already there. No markup rewrite. Works for any block comment JSON: colors, spacing, layout, border, className, typography, content (text on core/paragraph, core/heading, core/button), url/alt on media blocks, and anything else in the block's attributes. Set a value to null to remove it. |
| 740 | |
| 741 | Use this for ALL tweaks to an existing block: style, content, borders, images, etc. Chain multiple calls in the same turn if you need to customize several blocks (e.g. after duplicate or insert, patch the new block's text, icon, and styling to match the user's request). Much faster than edit-block for incremental changes. |
| 742 | |
| 743 | When the user references text inside a container (a paragraph inside a column, a button inside a group), target the LEAF block's clientId — not the container's. |
| 744 | |
| 745 | If the block uses nfd-* utility classes that control the property you're changing, include "className" with that class removed in the same patch — the CSS class otherwise silently overrides your attribute. |
| 746 | DESC; |
| 747 | |
| 748 | blu_register_ability( |
| 749 | 'blu/update-block-attrs', |
| 750 | array( |
| 751 | 'label' => 'Update Block Attributes', |
| 752 | 'description' => $description, |
| 753 | 'category' => 'blu-mcp', |
| 754 | 'input_schema' => array( |
| 755 | 'type' => 'object', |
| 756 | 'properties' => array( |
| 757 | 'client_id' => array( |
| 758 | 'type' => 'string', |
| 759 | 'description' => 'The clientId of the block to update.', |
| 760 | ), |
| 761 | 'attributes' => array( |
| 762 | 'type' => 'object', |
| 763 | 'description' => 'An object containing the attributes to merge into the block. All attribute keys (style, className, color, fontSize, etc.) MUST be nested inside this "attributes" object — never place them at the top level alongside client_id. Nested objects are deep-merged. Set a key to null to remove it. Correct: {"attributes": {"style": {"color": {"background": "#fff"}}}}. Wrong: {"style": {"color": {"background": "#fff"}}}.', |
| 764 | ), |
| 765 | ), |
| 766 | 'required' => array( 'client_id', 'attributes' ), |
| 767 | ), |
| 768 | 'execute_callback' => function ( $input ) { |
| 769 | if ( empty( $input['client_id'] ) || empty( $input['attributes'] ) ) { |
| 770 | return blu_prepare_ability_response( 400, array( 'message' => 'client_id and attributes are required' ) ); |
| 771 | } |
| 772 | |
| 773 | return blu_prepare_ability_response( |
| 774 | 200, |
| 775 | array( |
| 776 | 'action' => 'update_block_attrs', |
| 777 | 'client_id' => sanitize_text_field( $input['client_id'] ), |
| 778 | 'attributes' => $input['attributes'], |
| 779 | 'message' => 'Block attribute update ready for execution', |
| 780 | ) |
| 781 | ); |
| 782 | }, |
| 783 | 'permission_callback' => fn() => current_user_can( 'edit_posts' ), |
| 784 | 'meta' => array( |
| 785 | 'annotations' => array( |
| 786 | 'readonly' => false, |
| 787 | 'destructive' => false, |
| 788 | 'idempotent' => true, |
| 789 | ), |
| 790 | 'mcp' => array( |
| 791 | 'public' => true, |
| 792 | 'type' => 'tool', |
| 793 | ), |
| 794 | ), |
| 795 | ) |
| 796 | ); |
| 797 | } |
| 798 | |
| 799 | /** |
| 800 | * Register ability to get block markup. |
| 801 | * |
| 802 | * @return void |
| 803 | */ |
| 804 | private function register_get_block_markup(): void { |
| 805 | blu_register_ability( |
| 806 | 'blu/get-block-markup', |
| 807 | array( |
| 808 | 'label' => 'Get Block Markup', |
| 809 | 'description' => 'Get the full HTML markup of a specific block and its inner blocks. Use this BEFORE calling blu/edit-block when you need to see the current markup of a block that is NOT the selected block. This ensures you can produce accurate replacement markup that preserves existing content the user didn\'t ask to change. You do NOT need to call this for the selected block — its markup is already provided in the context.', |
| 810 | 'category' => 'blu-mcp', |
| 811 | 'input_schema' => array( |
| 812 | 'type' => 'object', |
| 813 | 'properties' => array( |
| 814 | 'client_id' => array( |
| 815 | 'type' => 'string', |
| 816 | 'description' => 'The clientId of the block to get markup for, from the block tree context', |
| 817 | ), |
| 818 | ), |
| 819 | 'required' => array( 'client_id' ), |
| 820 | ), |
| 821 | 'execute_callback' => function ( $input ) { |
| 822 | // Validate required fields |
| 823 | if ( empty( $input['client_id'] ) ) { |
| 824 | return blu_prepare_ability_response( 400, array( 'message' => 'client_id is required' ) ); |
| 825 | } |
| 826 | |
| 827 | // Return action data for client-side execution |
| 828 | // The client intercepts this and returns serialized block markup directly |
| 829 | return blu_prepare_ability_response( |
| 830 | 200, |
| 831 | array( |
| 832 | 'action' => 'get_block_markup', |
| 833 | 'client_id' => sanitize_text_field( $input['client_id'] ), |
| 834 | 'message' => 'Block markup request ready for execution', |
| 835 | ) |
| 836 | ); |
| 837 | }, |
| 838 | 'permission_callback' => fn() => current_user_can( 'edit_posts' ), |
| 839 | 'meta' => array( |
| 840 | 'annotations' => array( |
| 841 | 'readonly' => true, |
| 842 | 'destructive' => false, |
| 843 | 'idempotent' => true, |
| 844 | ), |
| 845 | 'mcp' => array( |
| 846 | 'public' => true, |
| 847 | 'type' => 'tool', |
| 848 | ), |
| 849 | ), |
| 850 | ) |
| 851 | ); |
| 852 | } |
| 853 | } |