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 / utils / v3-json-schema-builder.php

v3-json-schema-builder.php in Elementor Website Builder – more than just a page builder 4.3.0-beta3, at modules/mcp/abilities/utils/v3-json-schema-builder.php

394 lines 10.5 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\Utils;
4
5 use Elementor\Modules\Mcp\Abilities\Appliers\V3\V3_Dynamic_Resolver;
6
7 if ( ! defined( 'ABSPATH' ) ) {
8 exit;
9 }
10
11 /**
12 * Builds a JSON-Schema shaped object from a V3 widget's legacy controls stack,
13 * filtered to an allowlist of behavior keys.
14 *
15 * Legacy control types are mapped to plain JSON Schema:
16 * - text / textarea / wysiwyg / code / hidden / color / date-time -> string
17 * - number -> number
18 * - switcher -> boolean-ish string ("yes" | "")
19 * - select / choose -> string with enum (from options keys or values)
20 * - url -> object { url, is_external, nofollow }
21 * - media -> object { url, id }
22 * - icons -> object { value, library }
23 * - slider -> object { size, unit }
24 * - dimensions -> object { top, right, bottom, left, unit, isLinked }
25 * - repeater -> array (item shape omitted; legacy repeater fields are not introspected)
26 *
27 * Layout wrappers (`section`, `tab`, `tabs`) are always dropped.
28 */
29 class V3_Json_Schema_Builder {
30
31 const LAYOUT_CONTROL_TYPES = [ 'section', 'tab', 'tabs' ];
32
33 /**
34 * @param mixed $controls Widget controls stack.
35 * @param string[]|null $allowed_keys When provided, only these keys are emitted.
36 * @return array{properties: array<string, array>, required: string[]}
37 */
38 public static function build( $controls, ?array $allowed_keys = null ): array {
39 if ( ! is_array( $controls ) || empty( $controls ) ) {
40 return [
41 'properties' => [],
42 'required' => [],
43 ];
44 }
45
46 $allowed_lookup = null === $allowed_keys
47 ? null
48 : array_fill_keys( $allowed_keys, true );
49
50 $properties = [];
51
52 foreach ( $controls as $control_key => $control ) {
53 if ( ! is_array( $control ) || ! is_string( $control_key ) ) {
54 continue;
55 }
56
57 if ( null !== $allowed_lookup && ! isset( $allowed_lookup[ $control_key ] ) ) {
58 continue;
59 }
60
61 $control_type = is_string( $control['type'] ?? null ) ? $control['type'] : null;
62
63 if ( $control_type && in_array( $control_type, self::LAYOUT_CONTROL_TYPES, true ) ) {
64 continue;
65 }
66
67 $entry = self::build_entry( $control, $control_type );
68 if ( null === $entry ) {
69 continue;
70 }
71
72 $properties[ $control_key ] = $entry;
73 }
74
75 return [
76 'properties' => $properties,
77 'required' => [],
78 ];
79 }
80
81 /**
82 * Shallow shape check against the schema object emitted by `build()`.
83 *
84 * This is not full JSON Schema validation — only `type`, `enum`, and one-level nested
85 * `properties.type` are enforced. V3 has no runtime `Props_Parser`; this catches the
86 * array-vs-scalar and unknown-enum classes without pulling in a general JSON-Schema validator.
87 *
88 * @param mixed $value Setting value to check.
89 * @param array|null $entry_schema Schema entry from `build()['properties'][$key]`.
90 * @return string|null Human-readable reason when shape mismatches; null when acceptable.
91 */
92 public static function check_value_shape( $value, ?array $entry_schema ): ?string {
93 if ( ! is_array( $entry_schema ) ) {
94 return null;
95 }
96
97 $expected_type = $entry_schema['type'] ?? null;
98
99 if ( isset( $entry_schema['enum'] ) && is_array( $entry_schema['enum'] ) ) {
100 if ( ! self::value_matches_enum( $value, $entry_schema['enum'] ) ) {
101 return sprintf( 'value must be one of [%s].', implode( ', ', array_map( 'strval', $entry_schema['enum'] ) ) );
102 }
103
104 return null;
105 }
106
107 if ( $expected_type && ! self::value_matches_type( $value, $expected_type ) ) {
108 return sprintf( 'invalid shape (expected %s, got %s).', $expected_type, self::json_type_of( $value ) );
109 }
110
111 if ( 'object' === $expected_type && is_array( $value ) && isset( $entry_schema['properties'] ) && is_array( $entry_schema['properties'] ) ) {
112 foreach ( $entry_schema['properties'] as $prop_key => $prop_schema ) {
113 if ( ! is_string( $prop_key ) || ! array_key_exists( $prop_key, $value ) ) {
114 continue;
115 }
116
117 $sub_expected = $prop_schema['type'] ?? null;
118
119 if ( $sub_expected && ! self::value_matches_type( $value[ $prop_key ], $sub_expected ) ) {
120 return sprintf( 'invalid shape at "%s" (expected %s, got %s).', $prop_key, $sub_expected, self::json_type_of( $value[ $prop_key ] ) );
121 }
122 }
123 }
124
125 return null;
126 }
127
128 /**
129 * @param array<string, mixed> $settings Settings keyed by control name.
130 * @param array $schema Output of `build()`.
131 * @return array{valid: array<string, mixed>, errors: array<string, string>}
132 */
133 public static function check_settings_shape( array $settings, array $schema ): array {
134 $valid = [];
135 $errors = [];
136
137 foreach ( $settings as $key => $value ) {
138 if ( ! is_string( $key ) ) {
139 continue;
140 }
141
142 $entry_schema = $schema['properties'][ $key ] ?? null;
143
144 if ( ! is_array( $entry_schema ) ) {
145 $errors[ $key ] = 'no schema for allowlisted key.';
146 continue;
147 }
148
149 $shape_error = self::check_value_shape( $value, $entry_schema );
150
151 if ( null !== $shape_error ) {
152 $errors[ $key ] = $shape_error;
153 continue;
154 }
155
156 $valid[ $key ] = $value;
157 }
158
159 return [
160 'valid' => $valid,
161 'errors' => $errors,
162 ];
163 }
164
165 private static function json_type_of( $value ): string {
166 if ( is_string( $value ) ) {
167 return 'string';
168 }
169
170 if ( is_bool( $value ) ) {
171 return 'boolean';
172 }
173
174 if ( is_int( $value ) || is_float( $value ) ) {
175 return 'number';
176 }
177
178 if ( null === $value ) {
179 return 'null';
180 }
181
182 if ( is_array( $value ) ) {
183 if ( empty( $value ) ) {
184 return 'array';
185 }
186
187 return array_keys( $value ) === range( 0, count( $value ) - 1 ) ? 'array' : 'object';
188 }
189
190 return gettype( $value );
191 }
192
193 private static function value_matches_type( $value, string $expected_type ): bool {
194 if ( is_array( $value ) && [] === $value && in_array( $expected_type, [ 'array', 'object' ], true ) ) {
195 return true;
196 }
197
198 if ( 'number' === $expected_type && is_numeric( $value ) ) {
199 return true;
200 }
201
202 return self::json_type_of( $value ) === $expected_type;
203 }
204
205 private static function value_matches_enum( $value, array $allowed_values ): bool {
206 foreach ( $allowed_values as $allowed ) {
207 if ( self::values_equal_loosely( $value, $allowed ) ) {
208 return true;
209 }
210 }
211
212 return false;
213 }
214
215 private static function values_equal_loosely( $left, $right ): bool {
216 if ( $left === $right ) {
217 return true;
218 }
219
220 if ( is_scalar( $left ) && is_scalar( $right ) ) {
221 return (string) $left === (string) $right;
222 }
223
224 return false;
225 }
226
227 private static function build_entry( array $control, ?string $control_type ): ?array {
228 $entry = self::type_entry( $control, $control_type );
229
230 if ( array_key_exists( 'default', $control ) && ! self::has_object_shape( $entry ) ) {
231 $entry['default'] = $control['default'];
232 }
233
234 $description = isset( $control['description'] ) && is_string( $control['description'] )
235 ? trim( strip_tags( $control['description'] ) )
236 : null;
237
238 if ( V3_Dynamic_Resolver::is_dynamic_capable( $control ) ) {
239 return self::wrap_with_dynamic_branch( $entry, $control['dynamic']['categories'] ?? [], $description );
240 }
241
242 if ( null !== $description ) {
243 $entry['description'] = $description;
244 }
245
246 return $entry;
247 }
248
249 private static function wrap_with_dynamic_branch( array $primitive_entry, array $categories, ?string $description ): array {
250 $dynamic_entry = [
251 'type' => 'object',
252 'required' => [ 'name' ],
253 'additionalProperties' => false,
254 'properties' => [
255 'name' => [ 'type' => 'string' ],
256 'settings' => [
257 'type' => 'object',
258 'additionalProperties' => true,
259 ],
260 ],
261 'description' => self::dynamic_branch_description( $categories ),
262 ];
263
264 $wrapped = [
265 'anyOf' => [
266 $primitive_entry,
267 $dynamic_entry,
268 ],
269 ];
270
271 if ( null !== $description ) {
272 $wrapped['description'] = $description;
273 }
274
275 return $wrapped;
276 }
277
278 private static function dynamic_branch_description( array $categories ): string {
279 if ( empty( $categories ) ) {
280 return 'Bind THIS value to a dynamic tag from elementor://dynamic-tags. Shape: { "name": "<tag>", "settings": { ... } }.';
281 }
282
283 return sprintf(
284 'Bind THIS value to a dynamic tag from elementor://dynamic-tags whose categories intersect [%s]. Shape: { "name": "<tag>", "settings": { ... } }.',
285 implode( ', ', array_map( 'strval', $categories ) )
286 );
287 }
288
289 private static function type_entry( array $control, ?string $control_type ): array {
290 switch ( $control_type ) {
291 case 'number':
292 return [ 'type' => 'number' ];
293
294 case 'switcher':
295 return [
296 'type' => 'string',
297 'enum' => [ 'yes', '' ],
298 ];
299
300 case 'select':
301 case 'select2':
302 case 'choose':
303 return self::enum_entry( $control );
304
305 case 'url':
306 return [
307 'type' => 'object',
308 'properties' => [
309 'url' => [ 'type' => 'string' ],
310 'is_external' => [
311 'type' => 'string',
312 'enum' => [ 'on', '' ],
313 ],
314 'nofollow' => [
315 'type' => 'string',
316 'enum' => [ 'on', '' ],
317 ],
318 ],
319 ];
320
321 case 'media':
322 return [
323 'type' => 'object',
324 'properties' => [
325 'url' => [ 'type' => 'string' ],
326 'id' => [ 'type' => 'number' ],
327 ],
328 ];
329
330 case 'icons':
331 return [
332 'type' => 'object',
333 'properties' => [
334 'value' => [ 'type' => 'string' ],
335 'library' => [ 'type' => 'string' ],
336 ],
337 ];
338
339 case 'slider':
340 return [
341 'type' => 'object',
342 'properties' => [
343 'size' => [ 'type' => 'number' ],
344 'unit' => [ 'type' => 'string' ],
345 ],
346 ];
347
348 case 'dimensions':
349 return [
350 'type' => 'object',
351 'properties' => [
352 'top' => [ 'type' => 'string' ],
353 'right' => [ 'type' => 'string' ],
354 'bottom' => [ 'type' => 'string' ],
355 'left' => [ 'type' => 'string' ],
356 'unit' => [ 'type' => 'string' ],
357 'isLinked' => [ 'type' => 'boolean' ],
358 ],
359 ];
360
361 case 'repeater':
362 return [
363 'type' => 'array',
364 'items' => [ 'type' => 'object' ],
365 ];
366
367 default:
368 return [ 'type' => 'string' ];
369 }
370 }
371
372 private static function enum_entry( array $control ): array {
373 $entry = [ 'type' => 'string' ];
374
375 $options = $control['options'] ?? null;
376 if ( is_array( $options ) && ! empty( $options ) ) {
377 $entry['enum'] = self::is_associative_array( $options )
378 ? array_keys( $options )
379 : array_values( $options );
380 }
381
382 return $entry;
383 }
384
385 private static function has_object_shape( array $entry ): bool {
386 $type = $entry['type'] ?? null;
387 return 'object' === $type || 'array' === $type;
388 }
389
390 private static function is_associative_array( array $arr ): bool {
391 return array_keys( $arr ) !== range( 0, count( $arr ) - 1 );
392 }
393 }
394