PluginProbe
Elementor Website Builder – more than just a page builder / 4.3.0-beta3
Elementor Website Builder – more than just a page builder v4.3.0-beta3
4.3.0-beta3 4.3.0-beta2 4.3.0-beta1 4.2.4 4.2.3 4.2.2 4.2.1 4.2.0 4.1.5 4.2.0-beta2 4.2.0-dev2 4.2.0-beta1 4.1.4 4.1.3 4.1.2 4.1.1 4.1.0 4.1.0-beta3 4.1.0-dev3 4.0.9 4.1.0-beta2 4.1.0-dev2 4.0.8 4.1.0-beta1 4.1.0-dev1 All 452 releases
elementor / modules / mcp / abilities / build-composition-ability.php

build-composition-ability.php in Elementor Website Builder – more than just a page builder 4.3.0-beta3, at modules/mcp/abilities/build-composition-ability.php

404 lines 13.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Elementor\Modules\Mcp\Abilities;
4
5 use Elementor\Core\Base\Document;
6 use Elementor\Core\Utils\Document\Document_Mutator;
7 use Elementor\Modules\Mcp\Abilities\Build_Composition\Composition_Persister;
8 use Elementor\Modules\Mcp\Abilities\Build_Composition\Xml_Parser;
9 use Elementor\Modules\Mcp\Abilities\Utils\Composition_Compiler;
10 use Elementor\Modules\Mcp\Abilities\Utils\Document_Mutation_Links;
11 use Elementor\Modules\Mcp\Abilities\Utils\Prompt_Loader;
12 use Elementor\Modules\Mcp\Abilities\Utils\Tool_Performance_Metrics;
13 use Elementor\Modules\Mcp\Events\Mcp_Event_Dispatcher;
14 use Elementor\Plugin;
15
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit;
18 }
19
20 class Build_Composition_Ability extends Abstract_Ability {
21
22 const CONFIGURATION_ID_ATTRIBUTE = Xml_Parser::CONFIGURATION_ID_ATTRIBUTE;
23 const DEFAULT_PARENT_ID = 'document';
24 const MODE_APPEND = 'append';
25 const MODE_REPLACE_CHILDREN = 'replace_children';
26
27 private ?Document_Mutator $mutator;
28
29 public function __construct( ?Document_Mutator $mutator = null ) {
30 $this->mutator = $mutator;
31 }
32
33 protected function get_ability_id(): string {
34 return 'elementor/build-composition';
35 }
36
37 public function is_exposed_via_proxy(): bool {
38 return false;
39 }
40
41 protected function get_definition(): Ability_Definition {
42 return new Ability_Definition(
43 __( 'Build Composition', 'elementor' ),
44 $this->get_ability_description(),
45 'elementor',
46 $this->get_output_schema(),
47 [
48 'annotations' => [
49 'readonly' => false,
50 'idempotent' => false,
51 'destructive' => true,
52 ],
53 ],
54 fn() => current_user_can( 'edit_posts' ),
55 $this->get_input_schema()
56 );
57 }
58
59 public function execute( $input = [] ) {
60 $started_at = hrtime( true );
61 $input = is_array( $input ) ? $input : [];
62
63 $post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
64 $parent_id = $input['parent_id'] ?? self::DEFAULT_PARENT_ID;
65 $dry_run = ! empty( $input['dry_run'] );
66 $mode = is_string( $input['mode'] ?? null ) ? $input['mode'] : self::MODE_APPEND;
67
68 $validation_error = $this->validate_input( $input );
69 if ( $validation_error ) {
70 $this->emit_mcp_build_composition_executed( $started_at, $post_id, $mode, $dry_run, $validation_error );
71 return $validation_error;
72 }
73
74 if ( ! current_user_can( 'edit_post', $post_id ) ) {
75 $error = new \WP_Error(
76 'elementor_forbidden',
77 __( 'You do not have permission to edit this post.', 'elementor' ),
78 [ 'status' => \WP_Http::FORBIDDEN ]
79 );
80 $this->emit_mcp_build_composition_executed( $started_at, $post_id, $mode, $dry_run, $error );
81 return $error;
82 }
83
84 $document = $this->resolve_document( $post_id );
85 if ( is_wp_error( $document ) ) {
86 $this->emit_mcp_build_composition_executed( $started_at, $post_id, $mode, $dry_run, $document );
87 return $document;
88 }
89
90 $elements_data = $document->get_elements_data();
91 $compiled = Composition_Compiler::make()->compile(
92 $input,
93 $document,
94 is_array( $elements_data ) ? $elements_data : [],
95 $parent_id
96 );
97 if ( is_wp_error( $compiled ) ) {
98 $this->emit_mcp_build_composition_executed( $started_at, $post_id, $mode, $dry_run, $compiled, null, [], $document );
99 return $compiled;
100 }
101
102 $subtrees = $compiled['elements'];
103 $warnings = $compiled['warnings'];
104 $warning_codes = $compiled['warning_codes'] ?? [];
105 $dom = $compiled['dom'];
106 $xml_parser = $compiled['xml_parser'];
107
108 if ( $dry_run ) {
109 $response = $this->build_response( $post_id, $document, $xml_parser, $dom, [], $warnings, $mode, [] );
110 $this->emit_mcp_build_composition_executed( $started_at, $post_id, $mode, $dry_run, null, $subtrees, $warning_codes, $document, $response, [] );
111 return $response;
112 }
113
114 $persister = new Composition_Persister( $this->get_mutator(), $xml_parser );
115 $persisted = $persister->insert_and_save( $document, $subtrees, $parent_id, $mode );
116 if ( is_wp_error( $persisted ) ) {
117 $this->emit_mcp_build_composition_executed( $started_at, $post_id, $mode, $dry_run, $persisted, $subtrees, $warning_codes, $document );
118 return $persisted;
119 }
120
121 $persister->embed_ids_into_dom( $dom, $persisted['tree'], $parent_id, $persisted['root_ids'] );
122
123 $response = $this->build_response( $post_id, $document, $xml_parser, $dom, $persisted['root_ids'], $warnings, $mode, $persisted['removed_ids'] );
124 $this->emit_mcp_build_composition_executed( $started_at, $post_id, $mode, $dry_run, null, $subtrees, $warning_codes, $document, $response, $persisted['removed_ids'] );
125
126 return $response;
127 }
128
129 private function emit_mcp_build_composition_executed(
130 int $started_at,
131 int $post_id,
132 string $mode,
133 bool $dry_run,
134 ?\WP_Error $error = null,
135 ?array $subtrees = null,
136 array $warning_codes = [],
137 ?Document $document = null,
138 array $response = [],
139 array $removed_ids = []
140 ): void {
141 $duration_ms = Tool_Performance_Metrics::duration_ms_since( $started_at );
142
143 $status = null === $error ? 'success' : 'error';
144 $error_code = null !== $error ? $error->get_error_code() : null;
145
146 $operations_count = 0;
147 $operations_by_type = [];
148 $class_attachments = 0;
149 $interactions_count = 0;
150 $removed_count = count( $removed_ids );
151
152 if ( null !== $subtrees ) {
153 $this->collect_composition_counts( $subtrees, $operations_count, $operations_by_type, $class_attachments, $interactions_count );
154 }
155
156 $style_input = [];
157 $vars_referenced = 0;
158 $document_type = null !== $document ? $this->resolve_document_type( $document ) : null;
159
160 $payload = [
161 'tool_name' => $this->get_ability_id(),
162 'status' => $status,
163 'duration_ms' => $duration_ms,
164 'post_id' => $post_id,
165 'mode' => $mode,
166 'dry_run' => $dry_run,
167 'operations_count' => $operations_count,
168 'operations_by_type' => $operations_by_type,
169 'class_attachments_count' => $class_attachments,
170 'interactions_applied_count' => $interactions_count,
171 'removed_count' => $removed_count,
172 'warning_count' => count( $warning_codes ),
173 'warning_types' => array_values( array_unique( $warning_codes ) ),
174 ];
175
176 if ( null !== $document_type ) {
177 $payload['document_type'] = $document_type;
178 }
179
180 if ( null !== $error_code ) {
181 $payload['error_code'] = $error_code;
182 }
183
184 Mcp_Event_Dispatcher::emit( 'mcp_build_composition_executed', $payload );
185 }
186
187 private function collect_composition_counts( array $subtrees, int &$count, array &$by_type, int &$class_attachments, int &$interactions ): void {
188 $stack = $subtrees;
189
190 while ( ! empty( $stack ) ) {
191 $node = array_pop( $stack );
192 $type = $node['widgetType'] ?? $node['elType'] ?? '';
193
194 if ( '' !== $type ) {
195 ++$count;
196 $by_type[ $type ] = ( $by_type[ $type ] ?? 0 ) + 1;
197 }
198
199 $classes = $node['settings']['classes']['value'] ?? [];
200 $class_attachments += count( array_filter( (array) $classes, fn( $c ) => is_string( $c ) && str_starts_with( $c, 'g-' ) ) );
201
202 if ( ! empty( $node['interactions']['items'] ) && is_array( $node['interactions']['items'] ) ) {
203 $interactions += count( $node['interactions']['items'] );
204 }
205
206 foreach ( $node['elements'] ?? [] as $child ) {
207 $stack[] = $child;
208 }
209 }
210 }
211
212 private function resolve_document_type( Document $document ): string {
213 return $document->get_name();
214 }
215
216 private function get_ability_description(): string {
217 return Prompt_Loader::load( 'build-composition' );
218 }
219
220 private function get_output_schema(): array {
221 return [
222 'type' => 'object',
223 'required' => [ 'success', 'post_id', 'root_element_ids', 'edit_url', 'version' ],
224 'properties' => [
225 'success' => [ 'type' => 'boolean' ],
226 'post_id' => [ 'type' => 'integer' ],
227 'root_element_ids' => [
228 'type' => 'array',
229 'items' => [ 'type' => 'string' ],
230 'description' => 'IDs of the created root-level elements.',
231 ],
232 'edit_url' => [
233 'type' => 'string',
234 'format' => 'uri',
235 'description' => 'Elementor editor URL for the document. Share with the user when they need a link (they must be logged into WordPress as an editor).',
236 ],
237 'version' => [ 'type' => 'string' ],
238 'resolved_xml' => [
239 'type' => 'string',
240 'description' => 'The XML with element IDs embedded.',
241 ],
242 'warnings' => [
243 'type' => 'array',
244 'items' => [ 'type' => 'string' ],
245 'description' => 'Non-fatal notices, e.g. props skipped because the target widget does not support them, or CSS that fell back to custom_css. The composition was still built.',
246 ],
247 'removed_element_ids' => [
248 'type' => 'array',
249 'items' => [ 'type' => 'string' ],
250 'description' => 'Element IDs removed when mode is replace_children (empty when none existed).',
251 ],
252 ],
253 ];
254 }
255
256 private function get_input_schema(): array {
257 return [
258 'type' => 'object',
259 'required' => [ 'post_id', 'xml_structure' ],
260 'properties' => [
261 'post_id' => [
262 'type' => 'integer',
263 'description' => 'WordPress post ID of the document to mutate.',
264 ],
265 'xml_structure' => [
266 'type' => 'string',
267 'description' => 'Valid XML structure with custom Elementor widget tags. Every element MUST have a unique configuration-id attribute (e.g. <e-heading configuration-id="hero-title"></e-heading>). No attributes, classes, IDs, or text nodes in XML.',
268 ],
269 'element_config' => [
270 'type' => 'object',
271 'default' => (object) [],
272 'description' => 'Record mapping configuration-id → plain widget settings matching elementor://widgets/schema/{type}. Keys MUST match configuration-id attributes in xml_structure. For <e-component> configuration-ids, the value is { component_id: int, overrides?: {<override_key>: <plain value>} } — see elementor/list-components.',
273 ],
274 'style' => [
275 'type' => 'object',
276 'default' => (object) [],
277 'description' => 'Record mapping configuration-id → plain CSS string. Supports &:hover/&:focus/&:active nesting and @media(--breakpoint) blocks. Keys MUST match configuration-id attributes in xml_structure.',
278 'additionalProperties' => [ 'type' => 'string' ],
279 ],
280 'classes' => [
281 'type' => 'object',
282 'default' => (object) [],
283 'description' => 'Record mapping configuration-id → list of existing global class labels to attach to that element. Create classes first via elementor/manage-classes.',
284 'additionalProperties' => [
285 'type' => 'array',
286 'items' => [ 'type' => 'string' ],
287 ],
288 ],
289 'interactions' => [
290 'type' => 'object',
291 'default' => (object) [],
292 'description' => 'Record mapping configuration-id → array of interaction items in the native shape. Read elementor://interactions/schema for the full shape and allowed enum values. Send [] for a configuration-id to clear its interactions.',
293 'additionalProperties' => [
294 'type' => 'array',
295 'items' => [ 'type' => 'object' ],
296 ],
297 ],
298 'parent_id' => [
299 'type' => 'string',
300 'default' => self::DEFAULT_PARENT_ID,
301 'description' => 'ID of the parent container. Omit to insert at document root.',
302 ],
303 'dry_run' => [
304 'type' => 'boolean',
305 'default' => false,
306 'description' => 'If true, validate and return resolved tree without persisting.',
307 ],
308 'mode' => [
309 'type' => 'string',
310 'enum' => [ self::MODE_APPEND, self::MODE_REPLACE_CHILDREN ],
311 'default' => self::MODE_APPEND,
312 'description' => 'append (default) inserts under parent_id; replace_children removes existing direct children of parent_id first, then inserts.',
313 ],
314 ],
315 ];
316 }
317
318 private function validate_input( array $input ): ?\WP_Error {
319 if ( empty( $input['post_id'] ) ) {
320 return new \WP_Error(
321 'invalid_input',
322 __( 'post_id is required.', 'elementor' ),
323 [ 'status' => \WP_Http::BAD_REQUEST ]
324 );
325 }
326
327 if ( empty( $input['xml_structure'] ) || ! is_string( $input['xml_structure'] ) ) {
328 return new \WP_Error(
329 'invalid_input',
330 __( 'xml_structure is required and must be a string.', 'elementor' ),
331 [ 'status' => \WP_Http::BAD_REQUEST ]
332 );
333 }
334
335 $mode = $input['mode'] ?? self::MODE_APPEND;
336 $valid_modes = [ self::MODE_APPEND, self::MODE_REPLACE_CHILDREN ];
337 if ( ! in_array( $mode, $valid_modes, true ) ) {
338 return new \WP_Error(
339 'invalid_input',
340 sprintf(
341 /* translators: 1: Provided mode value, 2: List of valid modes */
342 __( 'Invalid mode "%1$s". Must be one of: %2$s', 'elementor' ),
343 $mode,
344 implode( ', ', $valid_modes )
345 ),
346 [ 'status' => \WP_Http::BAD_REQUEST ]
347 );
348 }
349
350 return null;
351 }
352
353 private function resolve_document( int $post_id ) {
354 $document = Plugin::$instance->documents->get_doc_or_auto_save( $post_id, get_current_user_id() )
355 ?? Plugin::$instance->documents->get( $post_id );
356
357 if ( ! $document ) {
358 return new \WP_Error(
359 'elementor_not_found',
360 __( 'Post not found.', 'elementor' ),
361 [ 'status' => \WP_Http::NOT_FOUND ]
362 );
363 }
364
365 return $document;
366 }
367
368 private function build_response(
369 int $post_id,
370 Document $document,
371 Xml_Parser $xml_parser,
372 \DOMDocument $dom,
373 array $root_ids,
374 array $warnings,
375 string $mode,
376 array $removed_ids
377 ): array {
378 $post = get_post( $post_id );
379
380 $response = [
381 'success' => true,
382 'post_id' => $post_id,
383 'root_element_ids' => $root_ids,
384 'edit_url' => $document->get_edit_url(),
385 'version' => $post ? $post->post_modified_gmt : current_time( 'mysql', true ),
386 'resolved_xml' => $xml_parser->serialize_children( $dom ),
387 ];
388
389 if ( ! empty( $warnings ) ) {
390 $response['warnings'] = $warnings;
391 }
392
393 if ( self::MODE_REPLACE_CHILDREN === $mode ) {
394 $response['removed_element_ids'] = $removed_ids;
395 }
396
397 return $response;
398 }
399
400 private function get_mutator(): Document_Mutator {
401 return $this->mutator ?? Document_Mutator::instance();
402 }
403 }
404