# elementor/4.3.1/modules/mcp/abilities/utils/v3-json-schema-builder.php

Elementor Website Builder – more than just a page builder, version 4.3.1. 394 lines.

- Page: https://pluginprobe.com/plugins/elementor/4.3.1/code/modules/mcp/abilities/utils/v3-json-schema-builder.php
- Raw: https://pluginprobe.com/plugins/elementor/4.3.1/raw/modules/mcp/abilities/utils/v3-json-schema-builder.php
- Modified: 2026-09-01T11:47:36+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/elementor/4.3.1/code/modules/mcp/abilities/utils/v3-json-schema-builder.php#L10-L20`.

```php
<?php

namespace Elementor\Modules\Mcp\Abilities\Utils;

use Elementor\Modules\Mcp\Abilities\Appliers\V3\V3_Dynamic_Resolver;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Builds a JSON-Schema shaped object from a V3 widget's legacy controls stack,
 * filtered to an allowlist of behavior keys.
 *
 * Legacy control types are mapped to plain JSON Schema:
 *  - text / textarea / wysiwyg / code / hidden / color / date-time -> string
 *  - number             -> number
 *  - switcher           -> boolean-ish string ("yes" | "")
 *  - select / choose    -> string with enum (from options keys or values)
 *  - url                -> object { url, is_external, nofollow }
 *  - media              -> object { url, id }
 *  - icons              -> object { value, library }
 *  - slider             -> object { size, unit }
 *  - dimensions         -> object { top, right, bottom, left, unit, isLinked }
 *  - repeater           -> array (item shape omitted; legacy repeater fields are not introspected)
 *
 * Layout wrappers (`section`, `tab`, `tabs`) are always dropped.
 */
class V3_Json_Schema_Builder {

	const LAYOUT_CONTROL_TYPES = [ 'section', 'tab', 'tabs' ];

	/**
	 * @param mixed         $controls     Widget controls stack.
	 * @param string[]|null $allowed_keys When provided, only these keys are emitted.
	 * @return array{properties: array<string, array>, required: string[]}
	 */
	public static function build( $controls, ?array $allowed_keys = null ): array {
		if ( ! is_array( $controls ) || empty( $controls ) ) {
			return [
				'properties' => [],
				'required' => [],
			];
		}

		$allowed_lookup = null === $allowed_keys
			? null
			: array_fill_keys( $allowed_keys, true );

		$properties = [];

		foreach ( $controls as $control_key => $control ) {
			if ( ! is_array( $control ) || ! is_string( $control_key ) ) {
				continue;
			}

			if ( null !== $allowed_lookup && ! isset( $allowed_lookup[ $control_key ] ) ) {
				continue;
			}

			$control_type = is_string( $control['type'] ?? null ) ? $control['type'] : null;

			if ( $control_type && in_array( $control_type, self::LAYOUT_CONTROL_TYPES, true ) ) {
				continue;
			}

			$entry = self::build_entry( $control, $control_type );
			if ( null === $entry ) {
				continue;
			}

			$properties[ $control_key ] = $entry;
		}

		return [
			'properties' => $properties,
			'required' => [],
		];
	}

	/**
	 * Shallow shape check against the schema object emitted by `build()`.
	 *
	 * This is not full JSON Schema validation — only `type`, `enum`, and one-level nested
	 * `properties.type` are enforced. V3 has no runtime `Props_Parser`; this catches the
	 * array-vs-scalar and unknown-enum classes without pulling in a general JSON-Schema validator.
	 *
	 * @param mixed      $value         Setting value to check.
	 * @param array|null $entry_schema  Schema entry from `build()['properties'][$key]`.
	 * @return string|null Human-readable reason when shape mismatches; null when acceptable.
	 */
	public static function check_value_shape( $value, ?array $entry_schema ): ?string {
		if ( ! is_array( $entry_schema ) ) {
			return null;
		}

		$expected_type = $entry_schema['type'] ?? null;

		if ( isset( $entry_schema['enum'] ) && is_array( $entry_schema['enum'] ) ) {
			if ( ! self::value_matches_enum( $value, $entry_schema['enum'] ) ) {
				return sprintf( 'value must be one of [%s].', implode( ', ', array_map( 'strval', $entry_schema['enum'] ) ) );
			}

			return null;
		}

		if ( $expected_type && ! self::value_matches_type( $value, $expected_type ) ) {
			return sprintf( 'invalid shape (expected %s, got %s).', $expected_type, self::json_type_of( $value ) );
		}

		if ( 'object' === $expected_type && is_array( $value ) && isset( $entry_schema['properties'] ) && is_array( $entry_schema['properties'] ) ) {
			foreach ( $entry_schema['properties'] as $prop_key => $prop_schema ) {
				if ( ! is_string( $prop_key ) || ! array_key_exists( $prop_key, $value ) ) {
					continue;
				}

				$sub_expected = $prop_schema['type'] ?? null;

				if ( $sub_expected && ! self::value_matches_type( $value[ $prop_key ], $sub_expected ) ) {
					return sprintf( 'invalid shape at "%s" (expected %s, got %s).', $prop_key, $sub_expected, self::json_type_of( $value[ $prop_key ] ) );
				}
			}
		}

		return null;
	}

	/**
	 * @param array<string, mixed> $settings Settings keyed by control name.
	 * @param array                $schema   Output of `build()`.
	 * @return array{valid: array<string, mixed>, errors: array<string, string>}
	 */
	public static function check_settings_shape( array $settings, array $schema ): array {
		$valid = [];
		$errors = [];

		foreach ( $settings as $key => $value ) {
			if ( ! is_string( $key ) ) {
				continue;
			}

			$entry_schema = $schema['properties'][ $key ] ?? null;

			if ( ! is_array( $entry_schema ) ) {
				$errors[ $key ] = 'no schema for allowlisted key.';
				continue;
			}

			$shape_error = self::check_value_shape( $value, $entry_schema );

			if ( null !== $shape_error ) {
				$errors[ $key ] = $shape_error;
				continue;
			}

			$valid[ $key ] = $value;
		}

		return [
			'valid' => $valid,
			'errors' => $errors,
		];
	}

	private static function json_type_of( $value ): string {
		if ( is_string( $value ) ) {
			return 'string';
		}

		if ( is_bool( $value ) ) {
			return 'boolean';
		}

		if ( is_int( $value ) || is_float( $value ) ) {
			return 'number';
		}

		if ( null === $value ) {
			return 'null';
		}

		if ( is_array( $value ) ) {
			if ( empty( $value ) ) {
				return 'array';
			}

			return array_keys( $value ) === range( 0, count( $value ) - 1 ) ? 'array' : 'object';
		}

		return gettype( $value );
	}

	private static function value_matches_type( $value, string $expected_type ): bool {
		if ( is_array( $value ) && [] === $value && in_array( $expected_type, [ 'array', 'object' ], true ) ) {
			return true;
		}

		if ( 'number' === $expected_type && is_numeric( $value ) ) {
			return true;
		}

		return self::json_type_of( $value ) === $expected_type;
	}

	private static function value_matches_enum( $value, array $allowed_values ): bool {
		foreach ( $allowed_values as $allowed ) {
			if ( self::values_equal_loosely( $value, $allowed ) ) {
				return true;
			}
		}

		return false;
	}

	private static function values_equal_loosely( $left, $right ): bool {
		if ( $left === $right ) {
			return true;
		}

		if ( is_scalar( $left ) && is_scalar( $right ) ) {
			return (string) $left === (string) $right;
		}

		return false;
	}

	private static function build_entry( array $control, ?string $control_type ): ?array {
		$entry = self::type_entry( $control, $control_type );

		if ( array_key_exists( 'default', $control ) && ! self::has_object_shape( $entry ) ) {
			$entry['default'] = $control['default'];
		}

		$description = isset( $control['description'] ) && is_string( $control['description'] )
			? trim( strip_tags( $control['description'] ) )
			: null;

		if ( V3_Dynamic_Resolver::is_dynamic_capable( $control ) ) {
			return self::wrap_with_dynamic_branch( $entry, $control['dynamic']['categories'] ?? [], $description );
		}

		if ( null !== $description ) {
			$entry['description'] = $description;
		}

		return $entry;
	}

	private static function wrap_with_dynamic_branch( array $primitive_entry, array $categories, ?string $description ): array {
		$dynamic_entry = [
			'type' => 'object',
			'required' => [ 'name' ],
			'additionalProperties' => false,
			'properties' => [
				'name' => [ 'type' => 'string' ],
				'settings' => [
					'type' => 'object',
					'additionalProperties' => true,
				],
			],
			'description' => self::dynamic_branch_description( $categories ),
		];

		$wrapped = [
			'anyOf' => [
				$primitive_entry,
				$dynamic_entry,
			],
		];

		if ( null !== $description ) {
			$wrapped['description'] = $description;
		}

		return $wrapped;
	}

	private static function dynamic_branch_description( array $categories ): string {
		if ( empty( $categories ) ) {
			return 'Bind THIS value to a dynamic tag from elementor://dynamic-tags. Shape: { "name": "<tag>", "settings": { ... } }.';
		}

		return sprintf(
			'Bind THIS value to a dynamic tag from elementor://dynamic-tags whose categories intersect [%s]. Shape: { "name": "<tag>", "settings": { ... } }.',
			implode( ', ', array_map( 'strval', $categories ) )
		);
	}

	private static function type_entry( array $control, ?string $control_type ): array {
		switch ( $control_type ) {
			case 'number':
				return [ 'type' => 'number' ];

			case 'switcher':
				return [
					'type' => 'string',
					'enum' => [ 'yes', '' ],
				];

			case 'select':
			case 'select2':
			case 'choose':
				return self::enum_entry( $control );

			case 'url':
				return [
					'type' => 'object',
					'properties' => [
						'url' => [ 'type' => 'string' ],
						'is_external' => [
							'type' => 'string',
							'enum' => [ 'on', '' ],
						],
						'nofollow' => [
							'type' => 'string',
							'enum' => [ 'on', '' ],
						],
					],
				];

			case 'media':
				return [
					'type' => 'object',
					'properties' => [
						'url' => [ 'type' => 'string' ],
						'id' => [ 'type' => 'number' ],
					],
				];

			case 'icons':
				return [
					'type' => 'object',
					'properties' => [
						'value' => [ 'type' => 'string' ],
						'library' => [ 'type' => 'string' ],
					],
				];

			case 'slider':
				return [
					'type' => 'object',
					'properties' => [
						'size' => [ 'type' => 'number' ],
						'unit' => [ 'type' => 'string' ],
					],
				];

			case 'dimensions':
				return [
					'type' => 'object',
					'properties' => [
						'top' => [ 'type' => 'string' ],
						'right' => [ 'type' => 'string' ],
						'bottom' => [ 'type' => 'string' ],
						'left' => [ 'type' => 'string' ],
						'unit' => [ 'type' => 'string' ],
						'isLinked' => [ 'type' => 'boolean' ],
					],
				];

			case 'repeater':
				return [
					'type' => 'array',
					'items' => [ 'type' => 'object' ],
				];

			default:
				return [ 'type' => 'string' ];
		}
	}

	private static function enum_entry( array $control ): array {
		$entry = [ 'type' => 'string' ];

		$options = $control['options'] ?? null;
		if ( is_array( $options ) && ! empty( $options ) ) {
			$entry['enum'] = self::is_associative_array( $options )
				? array_keys( $options )
				: array_values( $options );
		}

		return $entry;
	}

	private static function has_object_shape( array $entry ): bool {
		$type = $entry['type'] ?? null;
		return 'object' === $type || 'array' === $type;
	}

	private static function is_associative_array( array $arr ): bool {
		return array_keys( $arr ) !== range( 0, count( $arr ) - 1 );
	}
}

```
