first commit

This commit is contained in:
User A0264400
2026-04-01 23:20:16 +03:00
commit a766acdc90
23071 changed files with 4933189 additions and 0 deletions

View File

@@ -0,0 +1,172 @@
<?php
namespace Elementor\Modules\Components;
use Elementor\Core\Utils\Collection;
use Elementor\Modules\Components\Documents\Component as Component_Document;
use Elementor\Plugin;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Circular_Dependency_Validator {
const COMPONENT_WIDGET_TYPE = 'e-component';
const MAX_RECURSION_DEPTH = 50;
private array $components_cache = [];
public static function make(): Circular_Dependency_Validator {
return new self();
}
public function validate( $component_id, array $elements, array $unsaved_components = [] ): array {
$inner_components_ids = $this->get_inner_component_ids( $elements );
if ( in_array( $component_id, $inner_components_ids, false ) ) {
return $this->build_error_response( $component_id );
}
foreach ( $inner_components_ids as $ref_id ) {
if ( $this->is_component_eventually_contains( $ref_id, $component_id, $unsaved_components, [] ) ) {
return $this->build_error_response( $component_id, $ref_id );
}
}
return [
'success' => true,
'messages' => [],
];
}
public function validate_new_components( Collection $items ): array {
$unsaved_components = [];
foreach ( $items->all() as $item ) {
$unsaved_components[ $item['uid'] ] = $item['elements'] ?? [];
}
foreach ( $unsaved_components as $uid => $elements ) {
$result = $this->validate( $uid, $elements, $unsaved_components );
if ( ! $result['success'] ) {
return $result;
}
}
return [
'success' => true,
'messages' => [],
];
}
private function is_component_eventually_contains( $component_id, $forbidden_id, array $unsaved_components, array $visited_path ): bool {
if ( in_array( $component_id, $visited_path, false ) ) {
return false;
}
if ( count( $visited_path ) >= self::MAX_RECURSION_DEPTH ) {
return false;
}
$elements = $this->get_elements_for_component( $component_id, $unsaved_components );
if ( empty( $elements ) ) {
return false;
}
$nested_ids = $this->get_inner_component_ids( $elements );
if ( in_array( $forbidden_id, $nested_ids, false ) ) {
return true;
}
$visited_path[] = $component_id;
foreach ( $nested_ids as $nested_id ) {
if ( $this->is_component_eventually_contains( $nested_id, $forbidden_id, $unsaved_components, $visited_path ) ) {
return true;
}
}
return false;
}
private function get_elements_for_component( $component_id, array $unsaved_components ): array {
if ( isset( $unsaved_components[ $component_id ] ) ) {
return $unsaved_components[ $component_id ];
}
return $this->get_component_elements( $component_id );
}
private function get_component_elements( $component_id ): array {
if ( ! is_int( $component_id ) ) {
return [];
}
if ( isset( $this->components_cache[ $component_id ] ) ) {
return $this->components_cache[ $component_id ];
}
$doc = Plugin::$instance->documents->get( $component_id );
if ( ! $doc instanceof Component_Document ) {
$this->components_cache[ $component_id ] = [];
return [];
}
$elements = $doc->get_elements_data();
$this->components_cache[ $component_id ] = $elements;
return $elements;
}
private function get_inner_component_ids( array $elements ): array {
$ids = [];
foreach ( $elements as $element ) {
$widget_type = $element['widgetType'] ?? null;
if ( self::COMPONENT_WIDGET_TYPE === $widget_type ) {
$component_id = $this->extract_component_id_from_settings( $element['settings'] ?? [] );
if ( null !== $component_id ) {
$ids[] = $component_id;
}
}
if ( ! empty( $element['elements'] ) ) {
$ids = array_merge( $ids, $this->get_inner_component_ids( $element['elements'] ) );
}
}
return array_unique( $ids, SORT_REGULAR );
}
private function extract_component_id_from_settings( array $settings ) {
return $settings['component_instance']['value']['component_id']['value'] ?? null;
}
private function build_error_response( $component_id, $via_component_id = null ): array {
if ( null === $via_component_id ) {
$message = sprintf(
// translators: %s: Component ID that references itself.
esc_html__( 'Circular dependency detected: Component "%s" references itself.', 'elementor' ),
$component_id
);
} else {
$message = sprintf(
// translators: %1$s: Component ID, %2$s: Component ID that creates the cycle.
esc_html__( 'Circular dependency detected: Component "%1$s" would create a cycle via component "%2$s".', 'elementor' ),
$component_id,
$via_component_id
);
}
return [
'success' => false,
'messages' => [ $message ],
];
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace Elementor\Modules\Components;
use Elementor\Modules\Components\Documents\Component as Component_Document;
use Elementor\Modules\Components\Document_Lock_Manager;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Component_Lock_Manager extends Document_Lock_Manager {
const ONE_HOUR = 60 * 60;
private static $instance = null;
public function __construct() {
parent::__construct( self::ONE_HOUR );
}
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
public function register_hooks() {
add_filter( 'heartbeat_received', [ $this, 'heartbeat_received' ], 10, 2 );
}
public function heartbeat_received( $response, $data ) {
if ( ! isset( $data['elementor_post_lock']['post_ID'] ) ) {
return $response;
}
$post_id = $data['elementor_post_lock']['post_ID'];
if ( ! $this->is_component_post( $post_id ) ) {
return $response;
}
$lock_data = $this->get_lock_data( $post_id );
$user_id = get_current_user_id();
if ( $user_id === (int) $lock_data['locked_by'] ) {
$this->extend_lock( $post_id );
}
return $response;
}
/**
* Unlock a component.
*
* @param int $post_id The component ID to unlock
* @return bool True if unlock was successful, false otherwise
* @throws \Exception If post is not a component type.
*/
public function unlock( $post_id ) {
if ( ! $this->is_component_post( $post_id ) ) {
throw new \Exception( 'Post is not a component type' );
}
$lock_data = $this->get_lock_data( $post_id );
$current_user_id = get_current_user_id();
if ( $lock_data['locked_by'] && (int) $lock_data['locked_by'] !== (int) $current_user_id ) {
return false;
}
return parent::unlock( $post_id );
}
/**
* Lock a component.
*
* @param int $post_id The component ID to lock
* @return bool|null True if lock was successful, null if locked by another user, false otherwise
* @throws \Exception If post is not a component type.
*/
public function lock( $post_id ) {
if ( ! $this->is_component_post( $post_id ) ) {
throw new \Exception( 'Post is not a component type' );
}
$lock_data = $this->get_lock_data( $post_id );
$is_expired = $this->is_lock_expired( $post_id );
if ( $is_expired ) {
parent::unlock( $post_id );
} elseif ( $lock_data['locked_by'] ) {
return null;
}
return parent::lock( $post_id );
}
/**
* Get lock data for a component.
*
* @param int $post_id The component ID
* @return array Lock data with 'locked_by' (int|null), 'locked_at' (int|null)
* @throws \Exception If post is not a component type.
*/
public function get_lock_data( $post_id ) {
if ( ! $this->is_component_post( $post_id ) ) {
throw new \Exception( 'Post is not a component type' );
}
return parent::get_lock_data( $post_id );
}
/**
* Extend the lock for a component.
*
* @param int $post_id The component ID
* @return bool|null True if extended successfully, null if not locked or locked by another user
* @throws \Exception If post is not a component type.
*/
public function extend_lock( $post_id ) {
if ( ! $this->is_component_post( $post_id ) ) {
throw new \Exception( 'Post is not a component type' );
}
$lock_data = $this->get_lock_data( $post_id );
if ( ! $lock_data['locked_by'] ) {
return null;
}
$current_user_id = get_current_user_id();
if ( (int) $lock_data['locked_by'] !== (int) $current_user_id ) {
return null;
}
return parent::extend_lock( $post_id );
}
private function is_component_post( $post_id ) {
return get_post_type( $post_id ) === Component_Document::TYPE;
}
}

View File

@@ -0,0 +1,213 @@
<?php
namespace Elementor\Modules\Components;
use Elementor\Core\Utils\Collection;
use Elementor\Modules\Components\Documents\Component as Component_Document;
use Elementor\Plugin;
use Elementor\Core\Base\Document;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Components_Repository {
public static function make(): Components_Repository {
return new self();
}
public function all(): Collection {
// Components count is limited to 100, if we increase this number, we need to iterate the posts in batches.
$posts = get_posts( [
'post_type' => Component_Document::TYPE,
'post_status' => 'any',
'posts_per_page' => Components_REST_API::MAX_COMPONENTS,
] );
$components = [];
foreach ( $posts as $post ) {
$component = $this->get( $post->ID );
if ( ! $component ) {
continue;
}
$components[] = [
'id' => $component->get_main_id(),
'title' => $component->get_post()->post_title,
'uid' => $component->get_component_uid(),
'is_archived' => $component->get_is_archived(),
'styles' => $this->extract_styles( $component->get_elements_data() ),
];
}
return Collection::make( $components );
}
public function get( $id, bool $include_autosave = true ) {
$doc = $include_autosave
? Plugin::$instance->documents->get_doc_or_auto_save( $id, get_current_user_id() )
: Plugin::$instance->documents->get( $id );
if ( ! $doc instanceof Component_Document ) {
return null;
}
return $doc;
}
public function create( string $title, array $content, string $status, string $uid, array $settings = [] ) {
$document = Plugin::$instance->documents->create(
Component_Document::get_type(),
[
'post_title' => $title,
'post_status' => $status,
],
[
Component_Document::COMPONENT_UID_META_KEY => $uid,
]
);
try {
$saved = $document->save( [
'elements' => $content,
'settings' => $settings,
] );
} catch ( \Exception $e ) {
$document->force_delete();
throw $e;
}
if ( ! $saved ) {
$document->force_delete();
throw new \Exception( 'Failed to create component' );
}
return $document->get_main_id();
}
private function extract_styles( array $elements, array $styles = [] ) {
foreach ( $elements as $element ) {
if ( isset( $element['styles'] ) ) {
$styles = array_merge( $styles, $element['styles'] );
}
if ( isset( $element['elements'] ) ) {
$styles = $this->extract_styles( $element['elements'], $styles );
}
}
return $styles;
}
public function archive( array $ids, string $status ) {
$failed_ids = [];
$success_ids = [];
foreach ( $ids as $id ) {
try {
$component = $this->get_component_for_edit( $id, $status );
if ( ! $component ) {
$failed_ids[] = $id;
continue;
}
$component->archive();
$success_ids[] = $id;
} catch ( \Exception $e ) {
$failed_ids[] = $id;
}
}
return [
'failedIds' => $failed_ids,
'successIds' => $success_ids,
];
}
public function update_title( int $component_id, string $title, string $status ): bool {
$component = $this->get_component_for_edit( $component_id, $status );
if ( ! $component ) {
return false;
}
return $component->update_title( $title );
}
/**
* Get the component for edit.
*
* @param int $component_id The component ID.
* @param string $target_status The target status, means the status the component should be saved as.
* @return ?Component_Document The component document for edit.
*
* If target status is an autosave / draft:
* - If the component main document is autosave / draft, it will return the main document.
* - If the component main document is published, it will create a new autosave document and return it.
* If target status is publish:
* - Will return the main document. If it's an autosave, it will be published later by the publish_component method.
*/
private function get_component_for_edit( int $component_id, string $target_status ): ?Component_Document {
$component = $this->get( $component_id );
if ( ! $component ) {
return null;
}
$autosave_statuses = [ Document::STATUS_AUTOSAVE, Document::STATUS_DRAFT ];
$autosave_exists = $component->is_autosave();
$should_create_autosave = in_array( $target_status, $autosave_statuses, true ) && ! $autosave_exists;
if ( ! $should_create_autosave ) {
return $component;
}
// Create a new autosave document, based on the published version.
return $component->get_autosave( 0, true );
}
public function publish_component( Component_Document $component ): bool {
try {
$main_id = $component->get_main_id();
$main_component = $this->get( $main_id, false );
$autosave = $main_component->get_newer_autosave();
if ( $autosave ) {
$success = $this->copy_autosave_data_to_main_component_document_and_publish( $autosave, $main_component, $main_id );
} else {
$success = $main_component->update_status( Document::STATUS_PUBLISH );
}
if ( ! $success ) {
throw new \Exception( 'Failed to publish component' );
}
} catch ( \Exception $e ) {
return false;
}
return true;
}
private function copy_autosave_data_to_main_component_document_and_publish( Component_Document $autosave, Component_Document $main_component_document, int $main_id ): bool {
$autosave_id = $autosave->get_post()->ID;
// Copy component custom meta keys from the autosave to the main component.
Plugin::$instance->db->copy_elementor_meta( $autosave_id, $main_id, Component_Document::COMPONENT_CUSTOM_META_KEYS );
$autosave_elements = $autosave->get_elements_data();
$autosave_title = $autosave->get_post()->post_title;
return $main_component_document->save( [
'elements' => $autosave_elements,
'settings' => [
'post_status' => Document::STATUS_PUBLISH,
'post_title' => $autosave_title,
],
] );
}
}

View File

@@ -0,0 +1,654 @@
<?php
namespace Elementor\Modules\Components;
use Elementor\Core\Base\Document;
use Elementor\Core\Utils\Api\Error_Builder;
use Elementor\Core\Utils\Api\Response_Builder;
use Elementor\Core\Utils\Collection;
use Elementor\Modules\Components\Documents\Component;
use Elementor\Modules\Components\OverridableProps\Component_Overridable_Props_Parser;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Components_REST_API {
const API_NAMESPACE = 'elementor/v1';
const API_BASE = 'components';
const LOCK_DOCUMENT_TYPE_NAME = 'components';
const STYLES_ROUTE = 'styles';
const MAX_COMPONENTS = 100;
private $repository = null;
public function register_hooks() {
add_action( 'rest_api_init', fn() => $this->register_routes() );
}
private function get_repository() {
if ( ! $this->repository ) {
$this->repository = new Components_Repository();
}
return $this->repository;
}
/**
* @return Component_Lock_Manager instance
*/
private function get_component_lock_manager() {
return Component_Lock_Manager::get_instance();
}
private function register_routes() {
register_rest_route( self::API_NAMESPACE, '/' . self::API_BASE, [
[
'methods' => 'GET',
'callback' => fn() => $this->route_wrapper( fn() => $this->get_components() ),
'permission_callback' => fn() => current_user_can( 'edit_posts' ),
],
] );
register_rest_route( self::API_NAMESPACE, '/' . self::API_BASE . '/' . self::STYLES_ROUTE, [
[
'methods' => 'GET',
'callback' => fn() => $this->route_wrapper( fn() => $this->get_styles() ),
'permission_callback' => fn() => current_user_can( 'edit_posts' ),
],
] );
register_rest_route( self::API_NAMESPACE, '/' . self::API_BASE, [
[
'methods' => 'POST',
'callback' => fn( $request ) => $this->route_wrapper( fn() => $this->create_components( $request ) ),
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'args' => [
'status' => [
'type' => 'string',
'enum' => [ Document::STATUS_PUBLISH, Document::STATUS_DRAFT, Document::STATUS_AUTOSAVE ],
'required' => true,
],
'items' => [
'type' => 'array',
'required' => true,
'items' => [
'type' => 'object',
'properties' => [
'uid' => [
'type' => 'string',
'required' => true,
],
'title' => [
'type' => 'string',
'required' => true,
'minLength' => 2,
'maxLength' => 200,
],
'elements' => [
'type' => 'array',
'required' => true,
'items' => [
'type' => 'object',
],
],
'settings' => [
'type' => 'object',
'required' => false,
],
],
],
],
],
],
] );
register_rest_route( self::API_NAMESPACE, '/' . self::API_BASE . '/create-validate', [
[
'methods' => 'POST',
'callback' => fn( $request ) => $this->route_wrapper( fn() => $this->create_validate_components( $request ) ),
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'args' => [
'items' => [
'type' => 'array',
'required' => true,
'items' => [
'type' => 'object',
'properties' => [
'uid' => [
'type' => 'string',
'required' => true,
],
'title' => [
'type' => 'string',
'required' => true,
'minLength' => 2,
'maxLength' => 200,
],
'elements' => [
'type' => 'array',
'required' => true,
'items' => [
'type' => 'object',
],
],
'settings' => [
'type' => 'object',
'required' => false,
],
],
],
],
],
],
] );
register_rest_route( self::API_NAMESPACE, '/' . self::API_BASE . '/overridable-props', [
[
'methods' => 'GET',
'callback' => fn( $request ) => $this->route_wrapper( fn() => $this->get_overridable_props( $request ) ),
'permission_callback' => fn() => current_user_can( 'edit_posts' ),
'args' => [
'componentId' => [
'type' => 'integer',
'required' => true,
'description' => 'The component ID to get overridable props for',
],
],
],
] );
register_rest_route( self::API_NAMESPACE, '/' . self::API_BASE . '/status', [
[
'methods' => 'PUT',
'callback' => fn( $request ) => $this->route_wrapper( fn() => $this->update_statuses( $request ) ),
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'args' => [
'status' => [
'type' => 'string',
'required' => true,
'enum' => [ Document::STATUS_PUBLISH ],
],
'ids' => [
'type' => 'array',
'required' => true,
'items' => [
'type' => 'number',
'required' => true,
],
],
],
],
] );
register_rest_route( self::API_NAMESPACE, '/' . self::API_BASE . '/lock', [
[
'methods' => 'POST',
'callback' => fn( $request ) => $this->route_wrapper( fn() => $this->lock_component( $request ) ),
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'args' => [
'componentId' => [
'type' => 'number',
'required' => true,
'description' => 'The component ID to unlock',
],
],
],
] );
register_rest_route( self::API_NAMESPACE, '/' . self::API_BASE . '/unlock', [
[
'methods' => 'POST',
'callback' => fn( $request ) => $this->route_wrapper( fn() => $this->unlock_component( $request ) ),
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'args' => [
'componentId' => [
'type' => 'number',
'required' => true,
'description' => 'The component ID to unlock',
],
],
],
] );
register_rest_route( self::API_NAMESPACE, '/' . self::API_BASE . '/lock-status', [
[
'methods' => 'GET',
'callback' => fn( $request ) => $this->route_wrapper( fn() => $this->get_lock_status( $request ) ),
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'args' => [
'componentId' => [
'type' => 'string',
'required' => true,
'description' => 'The component ID to check lock status',
],
],
],
] );
register_rest_route( self::API_NAMESPACE, '/' . self::API_BASE . '/archive', [
[
'methods' => 'POST',
'callback' => fn( $request ) => $this->route_wrapper( fn() => $this->archive_components( $request ) ),
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'args' => [
'componentIds' => [
'type' => 'array',
'items' => [
'type' => 'number',
'required' => true,
],
'required' => true,
'description' => 'The component IDs to archive',
],
'status' => [
'type' => 'string',
'enum' => [ Document::STATUS_PUBLISH, Document::STATUS_DRAFT, Document::STATUS_AUTOSAVE ],
'required' => true,
],
],
],
] );
register_rest_route( self::API_NAMESPACE, '/' . self::API_BASE . '/update-titles', [
[
'methods' => 'POST',
'callback' => fn( $request ) => $this->route_wrapper( fn() => $this->update_components_title( $request ) ),
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'args' => [
'components' => [
'type' => 'array',
'required' => true,
'items' => [
'type' => 'object',
'properties' => [
'componentId' => [
'type' => 'number',
'required' => true,
'description' => 'The component ID to update title',
],
'title' => [
'type' => 'string',
'required' => true,
'description' => 'The new title for the component',
],
],
],
],
'status' => [
'type' => 'string',
'enum' => [ Document::STATUS_PUBLISH, Document::STATUS_DRAFT, Document::STATUS_AUTOSAVE ],
'required' => true,
],
],
],
] );
}
private function get_components() {
$components = $this->get_repository()->all();
$components_list = array_values( $components
->map( fn( $component ) => [
'id' => $component['id'],
'name' => $component['title'],
'uid' => $component['uid'],
'isArchived' => $component['is_archived'] ?? false,
] )
->all() );
return Response_Builder::make( $components_list )->build();
}
private function get_styles() {
$components = $this->get_repository()->all();
$styles = [];
$components->each( function( $component ) use ( &$styles ) {
$styles[ $component['id'] ] = $component['styles'];
} );
return Response_Builder::make( $styles )->build();
}
private function get_overridable_props( \WP_REST_Request $request ) {
$component_id = (int) $request->get_param( 'componentId' );
if ( ! $component_id ) {
return Error_Builder::make( 'invalid_component_id' )
->set_status( 400 )
->set_message( __( 'Invalid component ID', 'elementor' ) )
->build();
}
$document = $this->get_repository()->get( $component_id );
if ( ! $document ) {
return Error_Builder::make( 'component_not_found' )
->set_status( 404 )
->set_message( __( 'Component not found', 'elementor' ) )
->build();
}
$overridable = $document->get_json_meta( Component::OVERRIDABLE_PROPS_META_KEY ) ?? null;
if ( empty( $overridable ) ) {
$overridable = null;
}
return Response_Builder::make( $overridable )->build();
}
private function create_components( \WP_REST_Request $request ) {
$save_status = $request->get_param( 'status' );
$items = Collection::make( $request->get_param( 'items' ) );
$components = $this->get_repository()->all();
$result = Save_Components_Validator::make( $components )->validate( $items );
if ( ! $result['success'] ) {
return Error_Builder::make( 'components_validation_failed' )
->set_status( 422 )
->set_message( 'Validation failed: ' . implode( ', ', $result['messages'] ) )
->build();
}
$circular_result = Circular_Dependency_Validator::make()->validate_new_components( $items );
if ( ! $circular_result['success'] ) {
return Error_Builder::make( 'circular_dependency_detected' )
->set_status( 422 )
->set_message( __( "Can't add this component - components that contain each other can't be nested.", 'elementor' ) )
->set_meta( [ 'caused_by' => $circular_result['messages'] ] )
->build();
}
$non_atomic_result = Non_Atomic_Widget_Validator::make()->validate_items( $items );
if ( ! $non_atomic_result['success'] ) {
return Error_Builder::make( Non_Atomic_Widget_Validator::ERROR_CODE )
->set_status( 422 )
->set_message( __( 'Components require atomic elements only. Remove widgets to create this component.', 'elementor' ) )
->set_meta( [ 'non_atomic_elements' => $non_atomic_result['non_atomic_elements'] ] )
->build();
}
$validation_errors = [];
$created = $items->map_with_keys( function ( $item ) use ( $save_status, &$validation_errors ) {
$title = sanitize_text_field( $item['title'] );
$content = $item['elements'];
$uid = $item['uid'];
try {
$settings = isset( $item['settings'] ) ? $this->parse_settings( $item['settings'] ) : [];
$status = Document::STATUS_AUTOSAVE === $save_status
? Document::STATUS_DRAFT
: $save_status;
$component_id = $this->get_repository()->create( $title, $content, $status, $uid, $settings );
return [ $uid => $component_id ];
} catch ( \Exception $e ) {
$validation_errors[ $uid ] = $e->getMessage();
return [ $uid => null ];
}
} );
if ( ! empty( $validation_errors ) ) {
return Error_Builder::make( 'settings_validation_failed' )
->set_status( 422 )
->set_message( 'Settings validation failed: ' . json_encode( $validation_errors ) )
->build();
}
return Response_Builder::make( $created->all() )
->set_status( 201 )
->build();
}
private function update_statuses( \WP_REST_Request $request ) {
$result = Collection::make( $request->get_param( 'ids' ) )
->reduce(
function ( $result, int $component_id ) {
$component = $this->get_repository()->get( $component_id );
if ( ! $component ) {
$result['failed'][] = $component_id;
return $result;
}
$publish_result = $this->get_repository()->publish_component( $component );
$result[ $publish_result ? 'success' : 'failed' ][] = $component_id;
return $result;
},
[
'success' => [],
'failed' => [],
]
);
return Response_Builder::make( $result )->build();
}
private function lock_component( \WP_REST_Request $request ) {
$component_id = $request->get_param( 'componentId' );
try {
$success = $this->get_component_lock_manager()->lock( $component_id );
} catch ( \Exception $e ) {
error_log( 'Components REST API lock_component error: ' . $e->getMessage() );
return Error_Builder::make( 'lock_failed' )
->set_status( 500 )
->set_message( __( 'Failed to lock component', 'elementor' ) )
->build();
}
if ( ! $success ) {
return Error_Builder::make( 'lock_failed' )
->set_status( 500 )
->set_message( __( 'Failed to lock component', 'elementor' ) )
->build();
}
return Response_Builder::make( [ 'locked' => $success ] )->build();
}
private function unlock_component( \WP_REST_Request $request ) {
$component_id = $request->get_param( 'componentId' );
try {
$success = $this->get_component_lock_manager()->unlock( $component_id );
} catch ( \Exception $e ) {
error_log( 'Components REST API unlock_component error: ' . $e->getMessage() );
return Error_Builder::make( 'unlock_failed' )
->set_status( 500 )
->set_message( __( 'Failed to unlock component', 'elementor' ) )
->build();
}
if ( ! $success ) {
return Error_Builder::make( 'unlock_failed' )
->set_status( 500 )
->set_message( __( 'Failed to unlock component', 'elementor' ) )
->build();
}
return Response_Builder::make( [ 'unlocked' => $success ] )->build();
}
private function get_lock_status( \WP_REST_Request $request ) {
$component_id = (int) $request->get_param( 'componentId' );
try {
$lock_manager = $this->get_component_lock_manager();
if ( $lock_manager->is_lock_expired( $component_id ) ) {
$lock_manager->unlock( $component_id );
}
$lock_data = $lock_manager->get_lock_data( $component_id );
$current_user_id = get_current_user_id();
// if current user is the lock user, return true
if ( $lock_data['locked_by'] && $lock_data['locked_by'] === $current_user_id ) {
return Response_Builder::make( [
'is_current_user_allow_to_edit' => true,
'locked_by' => get_user_by( 'id', $lock_data['locked_by'] )->display_name,
] )->build();
}
// if the user is not the lock user, return false
if ( $lock_data['locked_by'] && $lock_data['locked_by'] !== $current_user_id ) {
return Response_Builder::make( [
'is_current_user_allow_to_edit' => false,
'locked_by' => get_user_by( 'id', $lock_data['locked_by'] )->display_name,
] )->build();
}
// if the component is not locked, return true
if ( ! $lock_data['locked_by'] ) {
return Response_Builder::make( [
'is_current_user_allow_to_edit' => true,
'locked_by' => null,
] )->build();
}
} catch ( \Exception $e ) {
error_log( 'Components REST API get_lock_status error: ' . $e->getMessage() );
return Error_Builder::make( 'get_lock_status_failed' )
->set_status( 500 )
->set_message( __( 'Failed to get lock status', 'elementor' ) )
->build();
}
}
private function archive_components( \WP_REST_Request $request ) {
$component_ids = $request->get_param( 'componentIds' );
$status = $request->get_param( 'status' );
try {
$result = $this->get_repository()->archive( $component_ids, $status );
} catch ( \Exception $e ) {
error_log( 'Components REST API archive_components error: ' . $e->getMessage() );
return Error_Builder::make( 'archive_failed' )
->set_meta( [ 'error' => $e->getMessage() ] )
->set_status( 500 )
->set_message( __( 'Failed to archive components', 'elementor' ) )
->build();
}
return Response_Builder::make( $result )->build();
}
private function update_components_title( \WP_REST_Request $request ) {
$failed_ids = [];
$success_ids = [];
$components = $request->get_param( 'components' );
$status = $request->get_param( 'status' );
foreach ( $components as $component ) {
$is_success = $this->get_repository()->update_title( $component['componentId'], $component['title'], $status );
if ( ! $is_success ) {
$failed_ids[] = $component['componentId'];
continue;
}
$success_ids[] = $component['componentId'];
}
return Response_Builder::make( [
'failedIds' => $failed_ids,
'successIds' => $success_ids,
] )->build();
}
private function create_validate_components( \WP_REST_Request $request ) {
$items = Collection::make( $request->get_param( 'items' ) );
$components = $this->get_repository()->all();
$result = Save_Components_Validator::make( $components )->validate( $items );
if ( ! $result['success'] ) {
return Error_Builder::make( 'components_validation_failed' )
->set_status( 422 )
->set_message( 'Validation failed: ' . implode( ', ', $result['messages'] ) )
->build();
}
$circular_result = Circular_Dependency_Validator::make()->validate_new_components( $items );
if ( ! $circular_result['success'] ) {
return Error_Builder::make( 'circular_dependency_detected' )
->set_status( 422 )
->set_message( __( "Can't add this component - components that contain each other can't be nested.", 'elementor' ) )
->set_meta( [ 'caused_by' => $circular_result['messages'] ] )
->build();
}
$non_atomic_result = Non_Atomic_Widget_Validator::make()->validate_items( $items );
if ( ! $non_atomic_result['success'] ) {
return Error_Builder::make( Non_Atomic_Widget_Validator::ERROR_CODE )
->set_status( 422 )
->set_message( __( 'Components require atomic elements only. Remove widgets to create this component.', 'elementor' ) )
->set_meta( [ 'non_atomic_elements' => $non_atomic_result['non_atomic_elements'] ] )
->build();
}
$validation_errors = $items->map_with_keys( function ( $item ) {
try {
if ( isset( $item['settings'] ) ) {
$this->parse_settings( $item['settings'] );
}
} catch ( \Exception $e ) {
return [ $item['uid'] => $e->getMessage() ];
}
return [ $item['uid'] => null ];
} )
->filter( fn( $value ) => null !== $value );
if ( ! $validation_errors->is_empty() ) {
return Error_Builder::make( 'settings_validation_failed' )
->set_status( 422 )
->set_message( 'Settings validation failed: ' . json_encode( $validation_errors->all() ) )
->build();
}
return Response_Builder::make()
->set_status( 200 )
->build();
}
private function parse_settings( array $settings ): array {
$result = [];
if ( empty( $settings ) ) {
return $result;
}
if ( isset( $settings['overridable_props'] ) ) {
$parser = Component_Overridable_Props_Parser::make();
$overridable_props_result = $parser->parse( $settings['overridable_props'] );
if ( ! $overridable_props_result->is_valid() ) {
throw new \Exception(
esc_html( 'Validation failed for overridable_props: ' . $overridable_props_result->errors()->to_string() )
);
}
$result['overridable_props'] = $overridable_props_result->unwrap();
}
return $result;
}
private function route_wrapper( callable $cb ) {
try {
$response = $cb();
} catch ( \Exception $e ) {
return Error_Builder::make( 'unexpected_error' )
->set_message( __( 'Something went wrong', 'elementor' ) )
->build();
}
return $response;
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace Elementor\Modules\Components;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Manages document locking for Elementor documents.
*
* This class handles locking/unlocking documents to prevent multiple users
* from editing the same document simultaneously.
*/
class Document_Lock_Manager {
// 5 minutes
const DEFAULT_TIME = 60 * 5;
private $lock_duration;
private const LOCK_USER_META = '_lock_user';
private const LOCK_TIME_META = '_lock_time';
private const LOCK_EDIT_LOCK_META = '_edit_lock';
/**
* Initialize the lock manager.
*
* @param int $lock_duration Lock duration in seconds (default: 300 = 5 minutes)
*/
public function __construct( $lock_duration = self::DEFAULT_TIME ) {
$this->lock_duration = $lock_duration;
}
/**
* Lock a document for the current user.
*
* @param int $document_id The document ID to lock
* @return bool True if lock was successful, false otherwise
*/
public function lock( $document_id ) {
try {
$user_id = get_current_user_id();
if ( ! $user_id ) {
return false;
}
$post = get_post( $document_id );
if ( ! $post ) {
return false;
}
if ( $this->is_lock_expired( $document_id ) ) {
$this->unlock( $document_id );
}
$existing_lock = $this->get_lock_data( $document_id );
if ( $existing_lock['locked_by'] ) {
return false;
}
update_post_meta( $document_id, self::LOCK_USER_META, $user_id );
update_post_meta( $document_id, self::LOCK_TIME_META, time() );
if ( ! function_exists( 'wp_set_post_lock' ) ) {
require_once ABSPATH . 'wp-admin/includes/post.php';
}
wp_set_post_lock( $document_id );
return true;
} catch ( \Exception $e ) {
error_log( 'Document lock error: ' . $e->getMessage() );
return false;
}
}
/**
* Unlock a document.
*
* @param int $document_id The document ID to unlock
* @return bool True if unlock was successful, false otherwise
*/
public function unlock( $document_id ) {
try {
delete_post_meta( $document_id, self::LOCK_USER_META );
delete_post_meta( $document_id, self::LOCK_TIME_META );
delete_post_meta( $document_id, self::LOCK_EDIT_LOCK_META );
return true;
} catch ( \Exception $e ) {
error_log( 'Document unlock error: ' . $e->getMessage() );
return false;
}
}
/**
* Check if a document is currently locked.
*
* @param int $document_id The document ID to check
* @return array Lock data with 'locked_by' (int|null), 'locked_at' (int|null)
*/
public function get_lock_data( $document_id ) {
$locked_by = $this->get_document_lock_user( $document_id );
$locked_at = $this->get_document_lock_time( $document_id );
return [
'locked_by' => $locked_by,
'locked_at' => $locked_at,
];
}
/**
* Check if a document lock has expired.
*
* @param int $document_id The document ID to check
* @return bool True if lock exists and is expired, false if not locked or not expired
*/
public function is_lock_expired( $document_id ) {
$lock_data = $this->get_lock_data( $document_id );
if ( ! $lock_data['locked_by'] ) {
return false;
}
return $lock_data['locked_at'] && (int) $lock_data['locked_at'] + $this->lock_duration <= time();
}
/**
* Extend the lock for a document.
*
* @param int $document_id The document ID
* @return bool True if extended successfully, false if not locked or locked by another user
*/
public function extend_lock( $document_id ) {
$lock_data = $this->get_lock_data( $document_id );
if ( ! $lock_data['locked_by'] ) {
return false;
}
$current_user_id = get_current_user_id();
if ( (int) $lock_data['locked_by'] !== (int) $current_user_id ) {
return false;
}
update_post_meta( $document_id, self::LOCK_TIME_META, time() );
return true;
}
public function get_document_lock_user( $document_id ) {
$lock_user_meta = get_post_meta( $document_id, self::LOCK_USER_META, true );
if ( $lock_user_meta ) {
return (int) $lock_user_meta;
}
if ( ! function_exists( 'wp_check_post_lock' ) ) {
require_once ABSPATH . 'wp-admin/includes/post.php';
}
$wp_lock_user = wp_check_post_lock( $document_id );
return $wp_lock_user ? (int) $wp_lock_user : null;
}
/**
* Get the lock time of a document.
*
* @param int $document_id The document ID to check
* @return int|null Lock time, or null if not locked
*/
public function get_document_lock_time( $document_id ) {
$lock_time = get_post_meta( $document_id, self::LOCK_TIME_META, true );
if ( $lock_time ) {
return (int) $lock_time;
}
return null;
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace Elementor\Modules\Components\Documents;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
}
class Component_Overridable_Prop {
/** @var string */
public $override_key;
/** @var string */
public $element_id;
/** @var string */
public $el_type;
/** @var string */
public $widget_type;
/** @var string */
public $prop_key;
/** @var string */
public $label;
/** @var array{ $$type: string, value: mixed } */
public $origin_value;
/** @var string */
public $group_id;
/** @var ?array{ $el_type: string, $widget_type: string, $prop_key: string } */
public $origin_prop_fields = null;
public function __construct( array $overridable_prop ) {
$this->override_key = $overridable_prop['overrideKey'];
$this->element_id = $overridable_prop['elementId'];
$this->el_type = $overridable_prop['elType'];
$this->widget_type = $overridable_prop['widgetType'];
$this->prop_key = $overridable_prop['propKey'];
$this->label = $overridable_prop['label'];
$this->origin_value = $overridable_prop['originValue'];
$this->group_id = $overridable_prop['groupId'] ?? null;
if ( isset( $overridable_prop['originPropFields'] ) ) {
$this->origin_prop_fields = [
'el_type' => $overridable_prop['originPropFields']['elType'],
'widget_type' => $overridable_prop['originPropFields']['widgetType'],
'prop_key' => $overridable_prop['originPropFields']['propKey'],
'element_id' => $overridable_prop['originPropFields']['elementId'],
];
}
}
public static function make( array $overridable_prop ): self {
return new self( $overridable_prop );
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Elementor\Modules\Components\Documents;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
}
class Component_Overridable_Props {
/** @var array{ [string]: Component_Overridable_Prop } */
public array $props;
public array $groups;
private function __construct( $overridable_props_meta ) {
if ( is_string( $overridable_props_meta ) && ! empty( $overridable_props_meta ) ) {
$overridable_props_meta = json_decode( $overridable_props_meta, true );
}
if ( empty( $overridable_props_meta ) ) {
$this->props = [];
$this->groups = [];
return;
}
$formatted_props = array_map( function( array $overridable_prop ) {
return Component_Overridable_Prop::make( $overridable_prop );
}, $overridable_props_meta['props'] ?? [] );
$this->props = $formatted_props;
$this->groups = $overridable_props_meta['groups'] ?? [];
}
public static function make( array $overridable_props_meta ): self {
return new self( $overridable_props_meta );
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace Elementor\Modules\Components\Documents;
use Elementor\Core\Base\Document;
use Elementor\Core\Utils\Api\Parse_Result;
use Elementor\Modules\Components\OverridableProps\Component_Overridable_Props_Parser;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
}
class Component extends Document {
const TYPE = 'elementor_component';
const COMPONENT_UID_META_KEY = '_elementor_component_uid';
const OVERRIDABLE_PROPS_META_KEY = '_elementor_component_overridable_props';
const ARCHIVED_META_KEY = '_elementor_component_is_archived';
const ARCHIVED_AT_META_KEY = '_elementor_component_archived_at';
const COMPONENT_CUSTOM_META_KEYS = [
self::COMPONENT_UID_META_KEY,
self::OVERRIDABLE_PROPS_META_KEY,
self::ARCHIVED_META_KEY,
self::ARCHIVED_AT_META_KEY,
];
public static function get_properties() {
$properties = parent::get_properties();
$properties['cpt'] = [ self::TYPE ];
return $properties;
}
public static function get_type() {
return self::TYPE;
}
public static function get_title() {
return esc_html__( 'Component', 'elementor' );
}
public static function get_plural_title() {
return esc_html__( 'Components', 'elementor' );
}
public static function get_labels(): array {
$plural_label = static::get_plural_title();
$singular_label = static::get_title();
$labels = [
'name' => $plural_label,
'singular_name' => $singular_label,
];
return $labels;
}
public static function get_supported_features(): array {
return [
'title',
'author',
'thumbnail',
'custom-fields',
'revisions',
'elementor',
];
}
public function get_component_uid() {
return $this->get_meta( self::COMPONENT_UID_META_KEY );
}
public function get_overridable_props(): Component_Overridable_Props {
$meta = $this->get_json_meta( self::OVERRIDABLE_PROPS_META_KEY );
return Component_Overridable_Props::make( $meta ?? [] );
}
public function archive() {
try {
$this->update_json_meta( self::ARCHIVED_META_KEY, [
'is_archived' => true,
'archived_at' => time(),
] );
} catch ( \Exception $e ) {
throw new \Exception( 'Failed to archive component: ' . esc_html( $e->getMessage() ) );
}
}
public function get_is_archived(): bool {
$archived_meta = $this->get_json_meta( self::ARCHIVED_META_KEY );
return $archived_meta['is_archived'] ?? false;
}
public function update_overridable_props( $data ): Parse_Result {
$parser = Component_Overridable_Props_Parser::make();
$result = $parser->parse( $data );
if ( ! $result->is_valid() ) {
return $result;
}
$sanitized_data = $result->unwrap();
$this->update_json_meta( self::OVERRIDABLE_PROPS_META_KEY, $sanitized_data );
return $result;
}
public function update_title( string $title ): bool {
$sanitized_title = sanitize_text_field( $title );
if ( empty( $sanitized_title ) ) {
return false;
}
return $this->update_post_field( 'post_title', $sanitized_title );
}
public function update_status( string $status ): bool {
if ( ! in_array( $status, [ Document::STATUS_PUBLISH, Document::STATUS_DRAFT, Document::STATUS_AUTOSAVE ], true ) ) {
return false;
}
return $this->update_post_field( 'post_status', $status );
}
private function update_post_field( string $field, $value ): bool {
$result = wp_update_post( [
'ID' => $this->post->ID,
$field => $value,
] );
$success = ! is_wp_error( $result ) && $result > 0;
if ( $success ) {
$this->refresh_post();
}
return $success;
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace Elementor\Modules\Components;
use Elementor\Core\Base\Module as BaseModule;
use Elementor\Core\Experiments\Manager as Experiments_Manager;
use Elementor\Modules\AtomicWidgets\PropsResolver\Transformers_Registry;
use Elementor\Modules\Components\Styles\Component_Styles;
use Elementor\Modules\Components\Documents\Component as Component_Document;
use Elementor\Modules\Components\Component_Lock_Manager;
use Elementor\Modules\Components\PropTypes\Component_Instance_Prop_Type;
use Elementor\Modules\Components\Transformers\Component_Instance_Transformer;
use Elementor\Modules\Components\PropTypes\Overridable_Prop_Type;
use Elementor\Modules\Components\Transformers\Overridable_Transformer;
use Elementor\Core\Base\Document;
use Elementor\Modules\Components\PropTypes\Override_Prop_Type;
use Elementor\Modules\Components\Transformers\Override_Transformer;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Module extends BaseModule {
const EXPERIMENT_NAME = 'e_components';
const PACKAGES = [ 'editor-components' ];
public function get_name() {
return 'components';
}
public function __construct() {
parent::__construct();
$this->register_component_post_type();
add_filter( 'elementor/editor/v2/packages', fn ( $packages ) => $this->add_packages( $packages ) );
add_filter( 'elementor/atomic-widgets/props-schema', fn ( $schema ) => $this->modify_props_schema( $schema ) );
add_action( 'elementor/documents/register', fn ( $documents_manager ) => $this->register_document_type( $documents_manager ) );
add_action( 'elementor/document/before_save', fn( Document $document, array $data ) => $this->validate_circular_dependencies( $document, $data ), 10, 2 );
add_action( 'elementor/document/after_save', fn( Document $document, array $data ) => $this->set_component_overridable_props( $document, $data ), 10, 2 );
add_filter( 'elementor/global_classes/additional_post_types', fn( $post_types ) => array_merge( $post_types, [ Component_Document::TYPE ] ) );
add_action( 'elementor/atomic-widgets/settings/transformers/register', fn ( $transformers ) => $this->register_settings_transformers( $transformers ) );
( Component_Lock_Manager::get_instance()->register_hooks() );
( new Component_Styles() )->register_hooks();
( new Components_REST_API() )->register_hooks();
}
public static function get_experimental_data() {
return [
'name' => self::EXPERIMENT_NAME,
'title' => esc_html__( 'Components', 'elementor' ),
'description' => esc_html__( 'Enable components.', 'elementor' ),
'hidden' => true,
'default' => Experiments_Manager::STATE_ACTIVE,
'release_status' => Experiments_Manager::RELEASE_STATUS_BETA,
];
}
public function get_widgets() {
return [
'Component_Instance',
];
}
private function add_packages( $packages ) {
return array_merge( $packages, self::PACKAGES );
}
private function modify_props_schema( array $schema ) {
return Overridable_Schema_Extender::make()->get_extended_schema( $schema );
}
private function register_component_post_type() {
register_post_type( Component_Document::TYPE, [
'label' => Component_Document::get_title(),
'labels' => Component_Document::get_labels(),
'public' => false,
'supports' => Component_Document::get_supported_features(),
] );
}
private function register_document_type( $documents_manager ) {
$documents_manager->register_document_type(
Component_Document::TYPE,
Component_Document::get_class_full_name()
);
}
private function validate_circular_dependencies( Document $document, array $data ) {
if ( ! $document instanceof Component_Document ) {
return;
}
if ( ! isset( $data['elements'] ) ) {
return;
}
$component_id = $document->get_main_id();
$elements = $data['elements'];
$result = Circular_Dependency_Validator::make()->validate( $component_id, $elements );
if ( ! $result['success'] ) {
throw new \Exception( esc_html__( "Can't add this component - components that contain each other can't be nested.", 'elementor' ) );
}
}
private function set_component_overridable_props( Document $document, array $data ) {
if ( ! isset( $data['settings'] ) ) {
return;
}
if ( ( ! $document instanceof Component_Document ) ||
( ! isset( $data['settings']['overridable_props'] ) )
) {
return;
}
/* @var Component_Document $document */
$result = $document->update_overridable_props( $data['settings']['overridable_props'] );
if ( ! $result->is_valid() ) {
throw new \Exception( esc_html( 'Settings validation failed for component overridable props: ' . $result->errors()->to_string() ) );
}
}
private function register_settings_transformers( Transformers_Registry $transformers ) {
$transformers->register( Component_Instance_Prop_Type::get_key(), new Component_Instance_Transformer() );
$transformers->register( Overridable_Prop_Type::get_key(), new Overridable_Transformer() );
$transformers->register( Override_Prop_Type::get_key(), new Override_Transformer() );
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace Elementor\Modules\Components;
use Elementor\Core\Utils\Collection;
use Elementor\Modules\AtomicWidgets\Utils\Utils;
use Elementor\Plugin;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Non_Atomic_Widget_Validator {
const ERROR_CODE = 'non_atomic_element_in_component';
const WIDGET_EL_TYPE = 'widget';
public static function make(): Non_Atomic_Widget_Validator {
return new self();
}
public function validate( array $elements ): array {
$non_atomic_elements = $this->find_non_atomic_elements( $elements );
if ( ! empty( $non_atomic_elements ) ) {
return $this->build_error_response( $non_atomic_elements );
}
return [
'success' => true,
'messages' => [],
];
}
public function validate_items( Collection $items ): array {
foreach ( $items->all() as $item ) {
$elements = $item['elements'] ?? [];
$result = $this->validate( $elements );
if ( ! $result['success'] ) {
return $result;
}
}
return [
'success' => true,
'messages' => [],
];
}
private function find_non_atomic_elements( array $elements ): array {
$non_atomic = [];
foreach ( $elements as $element ) {
$el_type = $element['elType'] ?? null;
$widget_type = $element['widgetType'] ?? null;
$element_type = $this->get_element_type( $el_type, $widget_type );
if ( $element_type && ! $this->is_element_atomic( $el_type, $widget_type ) ) {
$non_atomic[] = $element_type;
}
if ( ! empty( $element['elements'] ) ) {
$nested_non_atomic = $this->find_non_atomic_elements( $element['elements'] );
$non_atomic = array_merge( $non_atomic, $nested_non_atomic );
}
}
return array_unique( $non_atomic );
}
private function get_element_type( ?string $el_type, ?string $widget_type ): ?string {
return $widget_type ?? $el_type;
}
private function is_element_atomic( ?string $el_type, ?string $widget_type ): bool {
if ( ! $el_type ) {
return false;
}
$element_instance = Plugin::$instance->elements_manager->get_element( $el_type, $widget_type );
if ( ! $element_instance ) {
return false;
}
return Utils::is_atomic( $element_instance );
}
private function build_error_response( array $non_atomic_elements ): array {
$message = sprintf(
// translators: %s: Comma-separated list of non-atomic element types.
esc_html__( 'Component contains non-supported elements: %s. Only atomic elements are allowed inside components.', 'elementor' ),
implode( ', ', $non_atomic_elements )
);
return [
'success' => false,
'code' => self::ERROR_CODE,
'messages' => [ $message ],
'non_atomic_elements' => $non_atomic_elements,
];
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace Elementor\Modules\Components\OverridableProps;
use Elementor\Core\Utils\Api\Parse_Result;
use Elementor\Modules\Components\Utils\Parsing_Utils;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Validates and sanitizes component overridable props object.
*
* Valid input example:
* ```
* [
* 'props' => [
* 'prop1_UUID' => [
* 'overrideKey' => 'prop1_UUID',
* 'label' => 'User Name',
* 'elementId' => '90d25e3',
* 'propKey' => 'title',
* 'elType' => 'widget',
* 'widgetType' => 'e-heading',
* 'originValue' => [
* '$$type' => 'html',
* 'value' => 'Jane Smith',
* ],
* 'groupId' => 'group1_UUID',
* ],
* ],
* 'groups' => [
* 'items' => [
* 'group1_UUID' => [
* 'id' => 'group1_UUID',
* 'label' => 'User Info',
* 'props' => [ 'prop1_UUID' ],
* ],
* ],
* 'order' => [ 'group1_UUID' ],
* ],
* ];
* ```
*/
class Component_Overridable_Props_Parser {
private Overridable_Props_Parser $props_parser;
private Overridable_Groups_Parser $groups_parser;
public function __construct(
Overridable_Props_Parser $props_parser,
Overridable_Groups_Parser $groups_parser
) {
$this->props_parser = $props_parser;
$this->groups_parser = $groups_parser;
}
public static function make(): self {
return new static(
Overridable_Props_Parser::make(),
Overridable_Groups_Parser::make()
);
}
/**
* @param array $data
*
* @return Parse_Result
*/
public function parse( array $data ): Parse_Result {
$result = Parse_Result::make();
if ( empty( $data ) ) {
return $result->wrap( [] );
}
$inner_fields_structure_result = $this->validate_inner_fields_structure( $data );
if ( ! $inner_fields_structure_result->is_valid() ) {
$result->errors()->merge( $inner_fields_structure_result->errors() );
return $result;
}
if ( empty( $data['props'] ) && empty( $data['groups'] ) ) {
return $result->wrap( [] );
}
$props_result = $this->props_parser->parse( $data['props'] );
if ( ! $props_result->is_valid() ) {
$result->errors()->merge( $props_result->errors() );
return $result;
}
$groups_result = $this->groups_parser->parse( $data['groups'] );
if ( ! $groups_result->is_valid() ) {
$result->errors()->merge( $groups_result->errors() );
return $result;
}
$parsed_props = $props_result->unwrap();
$parsed_groups = $groups_result->unwrap();
$validation_result = $this->validate( $parsed_props, $parsed_groups );
if ( ! $validation_result->is_valid() ) {
$result->errors()->merge( $validation_result->errors() );
return $result;
}
return $this->sanitize( $parsed_props, $parsed_groups );
}
private function validate_inner_fields_structure( array $data ): Parse_Result {
$result = Parse_Result::make();
$inner_fields = [ 'props', 'groups' ];
foreach ( $inner_fields as $inner_field ) {
if ( ! isset( $data[ $inner_field ] ) ) {
$result->errors()->add( $inner_field, 'missing' );
return $result;
}
if ( ! is_array( $data[ $inner_field ] ) ) {
$result->errors()->add( $inner_field, 'invalid_structure' );
return $result;
}
}
return $result;
}
private function validate( array $props, array $groups ): Parse_Result {
$result = Parse_Result::make();
$group_items = $groups['items'];
$props_in_groups = [];
foreach ( $group_items as $group_id => $group ) {
foreach ( $group['props'] as $prop_id ) {
if ( ! isset( $props[ $prop_id ] ) ) {
$result->errors()->add( "groups.items.$group_id.props.$prop_id", 'prop_not_found_in_props' );
} else {
$props_in_groups[ $prop_id ] = $group_id;
}
}
}
foreach ( $props as $prop_id => $prop ) {
if ( ! isset( $props_in_groups[ $prop_id ] ) || $prop['groupId'] !== $props_in_groups[ $prop_id ] ) {
$result->errors()->add( "props.$prop_id.groupId", 'mismatching_value_with_groups.items.props' );
}
}
$duplicate_labels_result = $this->check_duplicate_labels_within_groups( $group_items, $props );
if ( ! $duplicate_labels_result->is_valid() ) {
$result->errors()->merge( $duplicate_labels_result->errors(), 'groups.items' );
}
return $result;
}
private function sanitize( array $props, array $groups ): Parse_Result {
return Parse_Result::make()->wrap( [
'props' => $props,
'groups' => $groups,
] );
}
private function check_duplicate_labels_within_groups( array $groups, array $props ): Parse_Result {
$result = Parse_Result::make();
foreach ( $groups as $group_id => $group ) {
$group_props = $group['props'];
$labels = array_map( fn( $prop_id ) => $props[ $prop_id ]['label'], $group_props );
$duplicate_labels = Parsing_Utils::get_duplicates( $labels );
if ( ! empty( $duplicate_labels ) ) {
$result->errors()->add( "$group_id.props", 'duplicate_labels: ' . implode( ', ', $duplicate_labels ) );
}
}
return $result;
}
}

View File

@@ -0,0 +1,215 @@
<?php
namespace Elementor\Modules\Components\OverridableProps;
use Elementor\Core\Utils\Api\Parse_Result;
use Elementor\Core\Utils\Collection;
use Elementor\Modules\Components\Utils\Parsing_Utils;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Overridable_Groups_Parser {
public static function make(): self {
return new static();
}
public function parse( array $groups ): Parse_Result {
$result = Parse_Result::make();
$structure_validation_result = $this->validate_structure( $groups );
if ( ! $structure_validation_result->is_valid() ) {
return $structure_validation_result;
}
$parsed_groups = $this->parse_groups_items( $groups['items'] );
if ( ! $parsed_groups->is_valid() ) {
$result->errors()->merge( $parsed_groups->errors() );
return $result;
}
$parsed_order = $this->parse_groups_order( $groups['order'] );
if ( ! $parsed_order->is_valid() ) {
$result->errors()->merge( $parsed_order->errors() );
return $result;
}
$validation_result = $this->validate( $parsed_groups->unwrap(), $parsed_order->unwrap() );
if ( ! $validation_result->is_valid() ) {
return $validation_result;
}
$sanitized_groups = $this->sanitize( $parsed_groups->unwrap(), $parsed_order->unwrap() );
return Parse_Result::make()->wrap( $sanitized_groups );
}
private function validate_structure( array $groups ): Parse_Result {
$result = Parse_Result::make();
$inner_fields = [ 'items', 'order' ];
foreach ( $inner_fields as $inner_field ) {
if ( ! isset( $groups[ $inner_field ] ) ) {
$result->errors()->add( "groups.$inner_field", 'missing' );
return $result;
}
if ( ! is_array( $groups[ $inner_field ] ) ) {
$result->errors()->add( "groups.$inner_field", 'invalid_structure' );
return $result;
}
}
foreach ( $groups['items'] as $group_id => $group ) {
if ( ! is_array( $group ) ) {
$result->errors()->add( "groups.items.$group_id", 'invalid_structure' );
continue;
}
$required_fields = [ 'id', 'label', 'props' ];
foreach ( $required_fields as $field ) {
if ( ! isset( $group[ $field ] ) ) {
$result->errors()->add( "groups.items.$group_id.$field", 'missing' );
}
}
if ( isset( $group['props'] ) && ! is_array( $group['props'] ) ) {
$result->errors()->add( "groups.items.$group_id.props", 'invalid_structure' );
}
}
return $result;
}
private function validate( array $items, array $order ): Parse_Result {
$result = Parse_Result::make();
$items_ids_collection = Collection::make( $items )->keys();
$order_collection = Collection::make( $order );
$excess_ids = $order_collection->diff( $items_ids_collection );
$missing_ids = $items_ids_collection->diff( $order_collection );
$excess_ids->each( fn( $id ) => $result->errors()->add( "groups.order.$id", 'excess' ) );
$missing_ids->each( fn( $id ) => $result->errors()->add( "groups.order.$id", 'missing' ) );
return $result;
}
private function parse_groups_items( array $items ): Parse_Result {
$result = Parse_Result::make();
$validate_groups_items_result = $this->validate_groups_items( $items );
if ( ! $validate_groups_items_result->is_valid() ) {
$result->errors()->merge( $validate_groups_items_result->errors() );
return $result;
}
return Parse_Result::make()->wrap( $this->sanitize_groups_items( $items ) );
}
private function validate_groups_items( array $items ): Parse_Result {
$result = Parse_Result::make();
$labels = [];
foreach ( $items as $group_id => $group ) {
if ( $group_id !== $group['id'] ) {
$result->errors()->add( "groups.items.$group_id.id", 'mismatching_value' );
}
$duplicate_props = Parsing_Utils::get_duplicates( $group['props'] );
if ( ! empty( $duplicate_props ) ) {
$result->errors()->add( "groups.items.$group_id.props", 'duplicate_props: ' . implode( ', ', $duplicate_props ) );
}
$labels[] = $group['label'];
}
$duplicate_labels = Parsing_Utils::get_duplicates( $labels );
if ( ! empty( $duplicate_labels ) ) {
$result->errors()->add( 'groups.items', 'duplicate_labels: ' . implode( ', ', $duplicate_labels ) );
}
return $result;
}
private function parse_groups_order( array $order ): Parse_Result {
$result = Parse_Result::make();
$validate_groups_order_result = $this->validate_groups_order( $order );
if ( ! $validate_groups_order_result->is_valid() ) {
return $validate_groups_order_result;
}
return Parse_Result::make()->wrap( $this->sanitize_groups_order( $order ) );
}
private function validate_groups_order( array $order ): Parse_Result {
$result = Parse_Result::make();
$order_collection = Collection::make( $order );
$non_string_items = $order_collection->some( fn( $item ) => ! is_string( $item ) );
if ( $non_string_items ) {
$result->errors()->add( 'groups.order', 'non_string_items' );
return $result;
}
if ( Parsing_Utils::get_duplicates( $order ) ) {
$result->errors()->add( 'groups.order', 'duplicate_ids' );
return $result;
}
return $result;
}
private function sanitize( array $items, array $order ): array {
return [
'items' => $items,
'order' => $order,
];
}
private function sanitize_groups_items( array $items ): array {
$sanitized_items = [];
foreach ( $items as $group_id => $group ) {
$sanitized_group_id = sanitize_key( $group_id );
$sanitized_items[ $sanitized_group_id ] = $this->sanitize_single_group( $group );
}
return $sanitized_items;
}
private function sanitize_single_group( array $group ): array {
return [
'id' => sanitize_key( $group['id'] ),
'label' => sanitize_text_field( $group['label'] ),
'props' => array_map( 'sanitize_key', $group['props'] ),
];
}
private function sanitize_groups_order( array $order ): array {
return Collection::make( $order )
->map( fn( $item ) => sanitize_key( $item ) )
->values();
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace Elementor\Modules\Components\OverridableProps;
use Elementor\Modules\Components\PropTypes\Override_Prop_Type;
use Elementor\Modules\Components\Utils\Parsing_Utils;
use Elementor\Core\Utils\Api\Parse_Result;
use Elementor\Modules\Components\PropTypes\Overridable_Prop_Type;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Overridable_Prop_Parser {
public static function make(): self {
return new static();
}
public function parse( array $prop ): Parse_Result {
$validation_result = $this->validate( $prop );
if ( ! $validation_result->is_valid() ) {
return $validation_result;
}
return $this->sanitize( $prop );
}
private function validate( array $prop ): Parse_Result {
$result = Parse_Result::make();
$required_fields = [
'overrideKey',
'label',
'elementId',
'elType',
'widgetType',
'propKey',
'groupId',
];
foreach ( $required_fields as $field ) {
if ( ! isset( $prop[ $field ] ) ) {
$result->errors()->add( $field, 'missing_field' );
}
}
if ( ! $result->is_valid() ) {
return $result;
}
$origin_value = $this->get_final_origin_value( $prop );
if ( ! empty( $origin_value ) ) {
$origin_value_prop_type = $this->get_origin_prop_type( $prop );
if ( ! $origin_value_prop_type->validate( $origin_value ) ) {
$result->errors()->add( 'originValue', 'invalid' );
return $result;
}
}
return $result;
}
private function sanitize( array $prop ): Parse_Result {
$result = Parse_Result::make();
$sanitized_origin_value = $this->get_sanitized_origin_value( $prop );
$sanitized_prop = [
'overrideKey' => sanitize_key( $prop['overrideKey'] ),
'label' => sanitize_text_field( $prop['label'] ),
'elementId' => sanitize_key( $prop['elementId'] ),
'propKey' => sanitize_text_field( $prop['propKey'] ),
'widgetType' => sanitize_text_field( $prop['widgetType'] ),
'elType' => sanitize_text_field( $prop['elType'] ),
'originValue' => $sanitized_origin_value,
'groupId' => sanitize_key( $prop['groupId'] ),
'originPropFields' => isset( $prop['originPropFields'] ) ? [
'elType' => sanitize_text_field( $prop['originPropFields']['elType'] ),
'widgetType' => sanitize_text_field( $prop['originPropFields']['widgetType'] ),
'propKey' => sanitize_text_field( $prop['originPropFields']['propKey'] ),
'elementId' => sanitize_key( $prop['originPropFields']['elementId'] ),
] : null,
];
return $result->wrap( $sanitized_prop );
}
private function is_with_origin_prop_fields( array $prop ): bool {
return ! empty( $prop['originPropFields'] );
}
private function get_origin_prop_type( array $prop ) {
if ( $this->is_with_origin_prop_fields( $prop ) ) {
return $this->get_origin_prop_type( $prop['originPropFields'] );
}
return Parsing_Utils::get_prop_type(
$prop['elType'],
$prop['widgetType'],
$prop['propKey'],
);
}
private function get_final_origin_value( array $prop ) {
if ( empty( $prop ) || empty( $prop['originValue'] ) ) {
return null;
}
if (
isset( $prop['originValue']['$$type'] ) &&
Override_Prop_Type::get_key() === $prop['originValue']['$$type']
) {
return $prop['originValue']['value']['override_value'];
}
return $prop['originValue'];
}
private function get_sanitized_origin_value( array $prop ) {
$origin_value = $this->get_final_origin_value( $prop );
$origin_prop_type = $this->get_origin_prop_type( $prop );
if ( ! empty( $origin_value ) ) {
$sanitized_value = $origin_prop_type->sanitize( $origin_value );
if ( Override_Prop_Type::get_key() === $prop['originValue']['$$type'] ) {
$raw_origin_value = $prop['originValue'];
$raw_origin_value['value']['override_value'] = $sanitized_value;
return $raw_origin_value;
}
return $sanitized_value;
}
return null;
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Elementor\Modules\Components\OverridableProps;
use Elementor\Core\Utils\Api\Parse_Result;
use Elementor\Modules\Components\Utils\Parsing_Utils;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Overridable_Props_Parser {
private Overridable_Prop_Parser $prop_parser;
public function __construct( Overridable_Prop_Parser $prop_parser ) {
$this->prop_parser = $prop_parser;
}
public static function make(): self {
return new static( Overridable_Prop_Parser::make() );
}
public function parse( array $props ): Parse_Result {
$parse_props_result = $this->parse_props( $props );
if ( ! $parse_props_result->is_valid() ) {
return $parse_props_result;
}
$parsed_props = $parse_props_result->unwrap();
$validation_result = $this->validate( $parsed_props );
if ( ! $validation_result->is_valid() ) {
return $validation_result;
}
return Parse_Result::make()->wrap( $parsed_props );
}
private function parse_props( array $props ): Parse_Result {
$result = Parse_Result::make();
$parsed_props = [];
foreach ( $props as $prop_id => $prop ) {
if ( ! is_array( $prop ) ) {
$result->errors()->add( "props.$prop_id", 'invalid_structure' );
continue;
}
$prop_result = $this->prop_parser->parse( $prop );
if ( ! $prop_result->is_valid() ) {
$result->errors()->merge( $prop_result->errors(), "props.$prop_id" );
continue;
}
$parsed_prop = $prop_result->unwrap();
$parsed_prop_id = sanitize_key( $prop_id );
if ( $parsed_prop_id != $parsed_prop['overrideKey'] ) {
$result->errors()->add( "props.$parsed_prop_id", 'mismatching_override_key' );
continue;
}
$parsed_props[ $parsed_prop_id ] = $parsed_prop;
}
return $result->wrap( $parsed_props );
}
private function validate( array $props ): Parse_Result {
$result = Parse_Result::make();
$duplicate_prop_keys_for_same_element = Parsing_Utils::get_duplicates( array_map( fn( $prop ) => $prop['elementId'] . '.' . $prop['propKey'], $props ) );
if ( ! empty( $duplicate_prop_keys_for_same_element ) ) {
$result->errors()->add( 'props', 'duplicate_prop_keys_for_same_element: ' . implode( ', ', $duplicate_prop_keys_for_same_element ) );
return $result;
}
return $result;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Elementor\Modules\Components;
use Elementor\Modules\AtomicWidgets\PropTypes\Utils\Prop_Types_Schema_Extender;
use Elementor\Modules\AtomicWidgets\PropTypes\Contracts\Prop_Type;
use Elementor\Modules\Components\PropTypes\Overridable_Prop_Type;
use Elementor\Modules\GlobalClasses\Utils\Atomic_Elements_Utils;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Overridable_Schema_Extender extends Prop_Types_Schema_Extender {
public static function make(): self {
return new static();
}
protected function get_prop_types_to_add( Prop_Type $prop_type ): array {
$is_ignore_overridable_applied = ! $prop_type->get_meta_item( Overridable_Prop_Type::META_KEY, true );
$is_classes_prop = Atomic_Elements_Utils::is_classes_prop( $prop_type );
if ( $is_ignore_overridable_applied || $is_classes_prop ) {
return [];
}
return [ Overridable_Prop_Type::make()->set_origin_prop_type( $prop_type ) ];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Elementor\Modules\Components\PropTypes;
use Elementor\Modules\AtomicWidgets\PropTypes\Base\Object_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Primitives\Number_Prop_Type;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Component_Instance_Prop_Type extends Object_Prop_Type {
public static function get_key(): string {
return 'component-instance';
}
protected function define_shape(): array {
return [
'component_id' => Number_Prop_Type::make()->required(),
'overrides' => Overrides_Prop_Type::make()->optional(),
];
}
public function validate_value( $value ): bool {
if ( ! parent::validate_value( $value ) ) {
return false;
}
$sanitized = parent::sanitize_value( $value );
$overrides = $sanitized['overrides']['value'] ?? [];
foreach ( $overrides as $item ) {
$component_id = null;
switch ( $item['$$type'] ) {
case Override_Prop_Type::get_key():
$component_id = $item['value']['schema_source']['id'];
break;
case Overridable_Prop_Type::get_key():
$override = $item['value']['origin_value'];
$component_id = $override['value']['schema_source']['id'];
break;
}
if ( $component_id !== $sanitized['component_id']['value'] ) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace Elementor\Modules\Components\PropTypes;
use Elementor\Plugin;
use Elementor\Modules\Components\Components_Repository;
use Elementor\Modules\Components\Documents\Component_Overridable_Prop;
use Elementor\Modules\Components\Documents\Component_Overridable_Props;
use Elementor\Modules\Components\Utils\Parsing_Utils;
use Elementor\Modules\AtomicWidgets\PropTypes\Contracts\Prop_Type;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Component_Override_Parser extends Override_Parser {
private static $repository;
public static function get_override_type(): string {
return 'component';
}
private ?Component_Overridable_Props $component_overridable_props = null;
public static function make(): self {
return new static();
}
public function validate_override( string $override_key, ?array $override_value, array $schema_source ): bool {
if ( ! isset( $schema_source['id'] ) ) {
return false;
}
$component_id = $schema_source['id'];
$component_overridable_props = $this->get_component_overridable_props( $component_id );
try {
$matching_overridable_prop = $this->get_matching_component_overridable_prop( sanitize_key( $override_key ), $component_overridable_props );
if ( ! $matching_overridable_prop ) {
// If the override is not one of the component overridable props we'll remove it in sanitize_value method.
// This is a valid scenario, as the user can delete overridable props from the component after the override created.
return true;
}
$prop_type = $this->get_overridable_prop_type( $matching_overridable_prop );
if ( null === $override_value ) {
return true;
}
return $prop_type->validate( $override_value );
} catch ( \Exception $e ) {
return false;
}
}
public function sanitize( $value ) {
['override_key' => $override_key, 'override_value' => $override_value, 'schema_source' => $schema_source] = $value;
$sanitized_override_key = sanitize_key( $override_key );
$sanitized_schema_source = [
'type' => sanitize_text_field( $schema_source['type'] ),
'id' => (int) $schema_source['id'],
];
$component_id = $sanitized_schema_source['id'];
$component_overridable_props = $this->get_component_overridable_props( $component_id );
try {
$matching_overridable_prop = $this->get_matching_component_overridable_prop( $sanitized_override_key, $component_overridable_props );
if ( ! $matching_overridable_prop ) {
return null;
}
$prop_type = $this->get_overridable_prop_type( $matching_overridable_prop );
return [
'override_key' => $sanitized_override_key,
'override_value' => null === $override_value ? null : $prop_type->sanitize( $override_value ),
'schema_source' => $sanitized_schema_source,
];
} catch ( \Exception $e ) {
return null;
}
}
private function get_matching_component_overridable_prop( string $override_key, ?Component_Overridable_Props $component_overridable_props ): ?Component_Overridable_Prop {
if ( ! $component_overridable_props || ! isset( $component_overridable_props->props[ $override_key ] ) ) {
return null;
}
return $component_overridable_props->props[ $override_key ];
}
private function get_overridable_prop_type( Component_Overridable_Prop $overridable ): ?Prop_Type {
if ( $overridable->origin_prop_fields ) {
['el_type' => $el_type, 'widget_type' => $widget_type, 'prop_key' => $prop_key] = $overridable->origin_prop_fields;
return Parsing_Utils::get_prop_type( $el_type, $widget_type, $prop_key );
}
return Parsing_Utils::get_prop_type( $overridable->el_type, $overridable->widget_type, $overridable->prop_key );
}
private function get_component_overridable_props( int $component_id ) {
if ( $this->component_overridable_props ) {
return $this->component_overridable_props;
}
$component = $this->get_repository()->get( $component_id );
if ( ! $component ) {
return null;
}
$this->component_overridable_props = $component->get_overridable_props();
return $this->component_overridable_props;
}
private function get_repository() {
if ( ! self::$repository ) {
self::$repository = new Components_Repository();
}
return self::$repository;
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Elementor\Modules\Components\PropTypes;
use Elementor\Modules\AtomicWidgets\PropTypes\Base\Plain_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Contracts\Prop_Type;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Overridable_Prop_Type extends Plain_Prop_Type {
const META_KEY = 'overridable';
/**
* Return a tuple that lets the developer ignore the component overridable prop type in the props schema
* using `Prop_Type::meta()`, e.g. `String_Prop_Type::make()->meta( Overridable_Prop_Type::ignore() )`.
*/
public static function ignore(): array {
return [ static::META_KEY, false ];
}
public static function get_key(): string {
return 'overridable';
}
protected function validate_value( $value ): bool {
if ( ! is_array( $value ) ) {
return false;
}
if ( ! array_key_exists( 'override_key', $value ) || ! is_string( $value['override_key'] ) ) {
return false;
}
if ( ! array_key_exists( 'origin_value', $value ) ) {
return false;
}
$origin_prop_type = $this->get_origin_prop_type();
if ( ! $origin_prop_type ) {
return false;
}
return $origin_prop_type->validate( $value['origin_value'] );
}
protected function sanitize_value( $value ): ?array {
['override_key' => $override_key, 'origin_value' => $origin_value] = $value;
$origin_prop_type = $this->get_origin_prop_type();
if ( ! $origin_prop_type ) {
return null;
}
$sanitized_override_key = sanitize_key( $override_key );
$sanitized_origin_value = is_null( $origin_value ) ? null : $origin_prop_type->sanitize( $origin_value );
return [
'override_key' => $sanitized_override_key,
'origin_value' => $sanitized_origin_value,
];
}
public function set_origin_prop_type( Prop_Type $origin_prop_type ) {
$this->settings['origin_prop_type'] = $origin_prop_type;
return $this;
}
public function get_origin_prop_type() {
return $this->settings['origin_prop_type'] ?? null;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Elementor\Modules\Components\PropTypes;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
abstract class Override_Parser {
public static function make(): self {
return new static();
}
abstract public static function get_override_type(): string;
/**
* @param array{override_key: string, override_value: ?array, schema_source: array} $value
*/
public function validate( $value ): bool {
[ 'override_key' => $override_key, 'override_value' => $override_value, 'schema_source' => $schema_source ] = $value;
if ( ! isset( $schema_source['type'] ) || $schema_source['type'] !== $this->get_override_type() ) {
return false;
}
return $this->validate_override( $override_key, $override_value, $schema_source );
}
abstract public function validate_override( string $override_key, ?array $override_value, array $schema_source ): bool;
/**
* @param array{override_key: string, override_value: array, schema_source: array} $value
*/
abstract public function sanitize( $value );
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Elementor\Modules\Components\PropTypes;
use Elementor\Modules\AtomicWidgets\PropTypes\Base\Plain_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Contracts\Prop_Type;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Override_Prop_Type extends Plain_Prop_Type {
public static function get_key(): string {
return 'override';
}
protected function validate_value( $value ): bool {
if ( ! is_array( $value ) ) {
return false;
}
$required_fields = [
'override_key' => 'is_string',
'override_value' => fn( $value ) => is_null( $value ) || is_array( $value ),
'schema_source' => 'is_array',
];
$is_valid_structure = true;
foreach ( $required_fields as $field => $validator ) {
if ( ! array_key_exists( $field, $value ) || ! call_user_func( $validator, $value[ $field ] ) ) {
$is_valid_structure = false;
break;
}
}
if ( ! $is_valid_structure ) {
return false;
}
$parser = $this->get_parser( sanitize_text_field( $value['schema_source']['type'] ) );
if ( ! $parser || ! $parser instanceof Override_Parser ) {
return false;
}
return $parser->validate( $value );
}
protected function sanitize_value( $value ): ?array {
$parser = $this->get_parser( sanitize_text_field( $value['schema_source']['type'] ) );
if ( ! $parser ) {
return null;
}
return $parser->sanitize( $value );
}
private function get_parser( string $schema_source_type ): ?Override_Parser {
switch ( $schema_source_type ) {
case Component_Override_Parser::get_override_type():
return Component_Override_Parser::make();
default:
return null;
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Elementor\Modules\Components\PropTypes;
use Elementor\Modules\AtomicWidgets\PropTypes\Base\Array_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Base\Plain_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Contracts\Prop_Type;
use Elementor\Modules\Components\PropTypes\Override_Parser;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Overrides_Prop_Type extends Array_Prop_Type {
public static function get_key(): string {
return 'overrides';
}
protected function define_item_type(): Prop_Type {
return Override_Prop_Type::make();
}
public function sanitize_value( $value ): array {
$sanitized = parent::sanitize_value( $value );
// array_values is used to format filtered overrides to indexed array
return array_values( array_filter( $sanitized, function( $item ) {
switch ( $item['$$type'] ) {
case 'override':
return null !== $item['value'];
case 'overridable':
$override = $item['value']['origin_value'];
return null !== $override['value'];
}
} ) );
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace Elementor\Modules\Components;
use Elementor\Core\Utils\Collection;
use Elementor\Modules\Components\Documents\Component;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Save_Components_Validator {
private Collection $components;
public function __construct( Collection $components ) {
$this->components = $components;
}
public static function make( Collection $components ) {
return new static( $components );
}
public function validate( Collection $data ) {
$errors = Collection::make( [
$this->validate_count( $data ),
$this->validate_duplicated_values( $data ),
] )->flatten();
if ( $errors->is_empty() ) {
return [
'success' => true,
'messages' => [],
];
}
return [
'success' => false,
'messages' => $errors->values(),
];
}
private function validate_count( Collection $data ): array {
$non_archived_components = $this->components->filter( fn ( $component ) => ! $component['is_archived'] );
$count = $non_archived_components->count() + $data->count();
if ( $count > Components_REST_API::MAX_COMPONENTS ) {
return [ esc_html__( 'Maximum number of components exceeded.', 'elementor' ) ];
}
return [];
}
private function validate_duplicated_values( Collection $data ): array {
return $data
->map( function ( $component ) use ( $data ) {
$errors = [];
$title = $component['title'];
$uid = $component['uid'];
$is_title_exists = $this->components->some(
fn ( $component ) => ! $component['is_archived'] && $component['title'] === $title
) || $data->filter(
fn ( $component ) => ! $component['title'] === $title
)->count() > 1;
if ( $is_title_exists ) {
$errors[] = [
sprintf(
// translators: %s Component title.
esc_html__( "Component title '%s' is duplicated.", 'elementor' ),
$title
),
];
}
$is_uid_exists = $this->components->some(
fn ( $component ) => $component['uid'] === $uid
) || $data->filter(
fn ( $component ) => $component['uid'] === $uid
)->count() > 1;
if ( $is_uid_exists ) {
$errors[] = [
sprintf(
// translators: %s Component uid.
esc_html__( "Component uid '%s' is duplicated.", 'elementor' ),
$uid
),
];
}
return $errors;
} )
->flatten()
->flatten()
->unique()
->values();
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace Elementor\Modules\Components\Styles;
use Elementor\Core\Base\Document;
use Elementor\Core\Utils\Collection;
use Elementor\Modules\AtomicWidgets\Styles\CacheValidity\Cache_Validity;
use Elementor\Modules\AtomicWidgets\Utils\Utils;
/**
* Component styles fetching for render
*/
class Component_Styles {
const CACHE_ROOT_KEY = 'component-styles-related-posts';
public function register_hooks() {
add_action( 'elementor/post/render', fn( $post_id ) => $this->render_post( $post_id ) );
add_action( 'elementor/document/after_save', fn( Document $document ) => $this->invalidate_cache(
[ $document->get_main_post()->ID ]
), 20, 2 );
add_action(
'elementor/core/files/clear_cache',
fn() => $this->invalidate_cache(),
);
}
private function render_post( string $post_id ) {
$cache_validity = new Cache_Validity();
if ( $cache_validity->is_valid( [ self::CACHE_ROOT_KEY, $post_id ] ) ) {
$component_ids = $cache_validity->get_meta( [ self::CACHE_ROOT_KEY, $post_id ] );
$this->declare_components_rendered( $component_ids );
return;
}
$components = $this->get_components_from_post( $post_id );
$component_ids = Collection::make( $components )
->filter( fn( $component ) => isset( $component['settings']['component_instance']['value']['component_id']['value'] ) )
->map( fn( $component ) => $component['settings']['component_instance']['value']['component_id']['value'] )
->unique()
->all();
$cache_validity->validate( [ self::CACHE_ROOT_KEY, $post_id ], $component_ids );
$this->declare_components_rendered( $component_ids );
}
private function declare_components_rendered( array $post_ids ) {
foreach ( $post_ids as $post_id ) {
do_action( 'elementor/post/render', $post_id );
}
}
private function get_components_from_post( string $post_id ): array {
$components = [];
Utils::traverse_post_elements( $post_id, function( $element_data ) use ( &$components ) {
if ( isset( $element_data['widgetType'] ) && 'e-component' === $element_data['widgetType'] ) {
$components[] = $element_data;
}
} );
return $components;
}
private function invalidate_cache( ?array $post_ids = null ) {
$cache_validity = new Cache_Validity();
if ( empty( $post_ids ) ) {
$cache_validity->invalidate( [ self::CACHE_ROOT_KEY ] );
return;
}
foreach ( $post_ids as $post_id ) {
$cache_validity->invalidate( [ self::CACHE_ROOT_KEY, $post_id ] );
}
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Elementor\Modules\Components\Transformers;
use Elementor\Modules\AtomicWidgets\PropsResolver\Props_Resolver_Context;
use Elementor\Modules\AtomicWidgets\PropsResolver\Transformer_Base;
use Elementor\Plugin;
use Elementor\Core\Base\Document as Component_Document;
use Elementor\Modules\Components\Components_Repository;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Component_Instance_Transformer extends Transformer_Base {
private static array $rendering_stack = [];
private static $repository;
public static function reset_rendering_stack(): void {
self::$rendering_stack = [];
}
public function transform( $value, Props_Resolver_Context $context ) {
$component_id = $value['component_id'];
if ( $this->is_circular_reference( $component_id ) ) {
return '';
}
self::$rendering_stack[] = $component_id;
$content = $this->get_rendered_content( $component_id );
array_pop( self::$rendering_stack );
return $content;
}
private function is_circular_reference( int $component_id ): bool {
return in_array( $component_id, self::$rendering_stack, true );
}
private function get_rendered_content( int $component_id ): string {
$should_show_autosave = is_preview();
$component = $this->get_repository()->get( $component_id, $should_show_autosave );
if ( ! $component || ! $this->should_render_content( $component ) ) {
return '';
}
Plugin::$instance->documents->switch_to_document( $component );
$data = $component->get_elements_data();
$data = apply_filters( 'elementor/frontend/builder_content_data', $data, $component_id );
$content = '';
if ( ! empty( $data ) ) {
ob_start();
$component->print_elements( $data );
$content = ob_get_clean();
$content = apply_filters( 'elementor/frontend/the_content', $content );
}
Plugin::$instance->documents->restore_document();
return $content;
}
private function should_render_content( Component_Document $document ): bool {
return ! $this->is_password_protected( $document ) &&
$document->is_built_with_elementor();
}
private function is_password_protected( $document ) {
return post_password_required( $document->get_post()->ID );
}
private function get_repository(): Components_Repository {
if ( ! self::$repository ) {
self::$repository = new Components_Repository();
}
return self::$repository;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Elementor\Modules\Components\Transformers;
use Elementor\Modules\AtomicWidgets\PropsResolver\Props_Resolver_Context;
use Elementor\Modules\AtomicWidgets\PropsResolver\Transformer_Base;
use Elementor\Modules\AtomicWidgets\Elements\Base\Render_Context;
use Elementor\Modules\Components\PropTypes\Override_Prop_Type;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Overridable_Transformer extends Transformer_Base {
public function transform( $value, Props_Resolver_Context $context ) {
[ 'override_key' => $override_key, 'origin_value' => $origin_value ] = $value;
$result = $origin_value;
$overrides = Render_Context::get( static::class )['overrides'] ?? [];
if ( isset( $overrides[ $override_key ] ) ) {
$matching_override_value = $overrides[ $override_key ];
if ( $this->is_origin_value_override( $origin_value ) ) {
$result = $this->transform_overridable_override( $origin_value, $matching_override_value, $context );
} else {
$result = $matching_override_value;
}
}
return $result;
}
private function is_origin_value_override( array $origin_value ): bool {
return isset( $origin_value['$$type'] ) && Override_Prop_Type::get_key() === $origin_value['$$type'];
}
private function transform_overridable_override( array $inner_override, array $outer_override_value, Props_Resolver_Context $context ): ?array {
$override_transformer = new Override_Transformer();
$transformed_inner_override = $override_transformer->transform( $inner_override['value'], $context );
return [
'override_key' => $transformed_inner_override['override_key'],
'override_value' => $outer_override_value,
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Elementor\Modules\Components\Transformers;
use Elementor\Modules\AtomicWidgets\PropsResolver\Props_Resolver_Context;
use Elementor\Modules\AtomicWidgets\PropsResolver\Transformer_Base;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Override_Transformer extends Transformer_Base {
public function transform( $value, Props_Resolver_Context $context ) {
return [
'override_key' => $value['override_key'],
'override_value' => $value['override_value'],
];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Elementor\Modules\Components\Utils;
use Elementor\Plugin;
use Elementor\Modules\AtomicWidgets\Elements\Base\Atomic_Element_Base;
use Elementor\Modules\AtomicWidgets\Elements\Base\Atomic_Widget_Base;
use Elementor\Modules\AtomicWidgets\Utils\Utils;
use Elementor\Modules\AtomicWidgets\PropTypes\Contracts\Prop_Type;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Parsing_Utils {
public static function get_prop_type( string $el_type, string $widget_type, string $prop_key ): Prop_Type {
$element = Plugin::$instance->elements_manager->get_element( $el_type, $widget_type );
if ( ! $element ) {
throw new \Exception( esc_html( "Invalid element: Element type $el_type with widget type $widget_type is not registered." ) );
}
$element_instance = new $element();
/** @var Atomic_Element_Base | Atomic_Widget_Base $element_instance */
if ( ! Utils::is_atomic( $element_instance ) ) {
throw new \Exception( esc_html( "Invalid element: Element type $el_type with widget type $widget_type is not an atomic element/widget." ) );
}
$props_schema = $element_instance->get_props_schema();
if ( ! isset( $props_schema[ $prop_key ] ) ) {
throw new \Exception( esc_html( "Prop key '$prop_key' does not exist in the schema of element '{$element_instance->get_element_type()}'." ) );
}
return $props_schema[ $prop_key ];
}
public static function get_duplicates( array $array ): array {
$duplicates = [];
$seen = [];
foreach ( $array as $item ) {
if ( in_array( $item, $seen, true ) ) {
if ( ! in_array( $item, $duplicates, true ) ) {
$duplicates[] = $item;
}
} else {
$seen[] = $item;
}
}
return $duplicates;
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace Elementor\Modules\Components\Widgets;
use Elementor\Modules\AtomicWidgets\Elements\Base\Atomic_Widget_Base;
use Elementor\Modules\AtomicWidgets\Elements\Base\Has_Template;
use Elementor\Modules\Components\PropTypes\Component_Instance_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropsResolver\Render_Props_Resolver;
use Elementor\Modules\Components\Transformers\Overridable_Transformer;
use Elementor\Modules\Components\PropTypes\Overridable_Prop_Type;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Component_Instance extends Atomic_Widget_Base {
use Has_Template;
public static function get_element_type(): string {
return 'e-component';
}
public function show_in_panel() {
return false;
}
public function get_title() {
return esc_html__( 'Component', 'elementor' );
}
public function get_keywords() {
return [ 'component' ];
}
public function get_icon() {
return 'eicon-components';
}
protected static function define_props_schema(): array {
return [
'component_instance' => Component_Instance_Prop_Type::make()->meta( Overridable_Prop_Type::ignore() )->required(),
];
}
protected function parse_editor_settings( array $data ): array {
$editor_data = parent::parse_editor_settings( $data );
if ( isset( $data['component_uid'] ) && is_string( $data['component_uid'] ) ) {
$editor_data['component_uid'] = sanitize_text_field( $data['component_uid'] );
}
return $editor_data;
}
protected function define_atomic_controls(): array {
return [];
}
protected function get_templates(): array {
return [
'elementor/elements/component' => __DIR__ . '/component.html.twig',
];
}
protected function define_render_context(): array {
$resolved_overrides = $this->get_resolved_overrides();
$merged_overrides = $this->get_merged_overrides( $resolved_overrides );
return [
'context_key' => Overridable_Transformer::class,
'context' => [ 'overrides' => $merged_overrides ],
];
}
private function get_resolved_overrides(): array {
$props = $this->get_settings();
$overrides = $props['component_instance']['value']['overrides'] ?? null;
if ( ! $overrides ) {
return [];
}
$component_schema = $this->get_props_schema();
$overrides_schema = $component_schema['component_instance']->get_shape_field( 'overrides' );
return Render_Props_Resolver::for_settings()->resolve( [ 'overrides' => $overrides_schema ], [ 'overrides' => $overrides ] );
}
private function get_merged_overrides( $value ): array {
$overrides_array = $value['overrides'] ?? [];
$overrides = [];
foreach ( $overrides_array as $override ) {
$overrides[ $override['override_key'] ] = $override['override_value'];
}
return $overrides;
}
}

View File

@@ -0,0 +1 @@
{{ settings.component_instance | raw }}