File: /home/dfwparty/www/wp-content/plugins/ultimate-elementor/abilities/class-uael-element-helpers.php
<?php
/**
* Elementor Element Helpers.
*
* Shared utilities for manipulating Elementor `_elementor_data` post meta:
* parse, find, insert, remove, save, and cache clearing.
*
* @package UAEL
* @since 1.45.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Class UAEL_Element_Helpers
*
* Static helper methods for Elementor element tree manipulation.
*
* @since 1.45.0
*/
class UAEL_Element_Helpers {
/**
* Parse `_elementor_data` JSON from a template post.
*
* @param int $post_id Template post ID.
* @return array|WP_Error Parsed element tree or error.
*/
public static function parse_elementor_data( $post_id ) {
$raw = get_post_meta( $post_id, '_elementor_data', true );
if ( empty( $raw ) ) {
return array();
}
$data = json_decode( $raw, true );
if ( ! is_array( $data ) ) {
return new \WP_Error(
'uael_invalid_elementor_data',
__( 'Template has invalid Elementor data.', 'uael' ),
array( 'status' => 500 )
);
}
return $data;
}
/**
* Save `_elementor_data` back to post meta and clear Elementor CSS cache.
*
* @param int $post_id Template post ID.
* @param array $data Element tree array.
* @return true|WP_Error True on success, error on failure.
*/
public static function save_elementor_data( $post_id, $data ) {
// Undo safety net: snapshot the current (pre-edit) Elementor data before we
// overwrite it, so an accidental AI change can be reverted via builder/undo.
// Captured on every save path (including the headless fallback below, which
// does not create an Elementor revision).
self::snapshot_for_undo( $post_id );
// Security: AI clients can supply arbitrary widget content. The fallback
// meta-save path below bypasses Elementor's own `unfiltered_html` gate, so
// sanitize raw-HTML-bearing widgets here for users who lack that capability.
// Applied to BOTH the Document API and the direct-meta paths as defense in depth.
if ( ! current_user_can( 'unfiltered_html' ) ) {
$data = self::sanitize_raw_html_widgets( $data );
}
// Try Elementor's Document save API first — triggers hooks, CSS regen, and cache clearing.
if ( class_exists( '\Elementor\Plugin' ) ) {
$document = \Elementor\Plugin::$instance->documents->get( $post_id );
if ( $document ) {
// Document API expects arrays, not stdClass (which we use for JSON {} encoding).
$clean_data = self::convert_stdclass_to_array( $data );
try {
$result = $document->save( array( 'elements' => $clean_data ) );
} catch ( \TypeError $e ) {
$result = new \WP_Error( 'uael_document_save_error', $e->getMessage() );
}
if ( ! is_wp_error( $result ) ) {
return true;
}
// Document save failed — fall through to direct meta save.
}
}
// Fallback: direct meta save (for CLI/non-browser contexts).
$json = wp_json_encode( $data );
if ( false === $json ) {
return new \WP_Error(
'uael_encode_failed',
__( 'Failed to encode Elementor data.', 'uael' ),
array( 'status' => 500 )
);
}
update_post_meta( $post_id, '_elementor_data', wp_slash( $json ) );
// Update Elementor meta so the post is recognized as an Elementor document.
update_post_meta( $post_id, '_elementor_edit_mode', 'builder' );
if ( defined( 'ELEMENTOR_VERSION' ) ) {
update_post_meta( $post_id, '_elementor_version', ELEMENTOR_VERSION );
}
self::clear_elementor_cache( $post_id );
// Also delete the cached CSS file from disk.
$upload_dir = wp_upload_dir();
$css_file = $upload_dir['basedir'] . '/elementor/css/post-' . absint( $post_id ) . '.css';
if ( file_exists( $css_file ) ) {
wp_delete_file( $css_file );
}
return true;
}
/**
* Meta key holding the single-level undo snapshot of `_elementor_data`.
*
* @since 1.45.0
* @var string
*/
const UNDO_SNAPSHOT_META = '_uael_ai_undo_snapshot';
/**
* Snapshot the current `_elementor_data` so the last AI edit can be reverted.
*
* Stores one level of undo (overwritten on each save, never accumulated) along
* with a timestamp and the acting user. No-op when the post has no data yet.
*
* @since 1.45.0
*
* @param int $post_id Post ID.
* @return void
*/
public static function snapshot_for_undo( $post_id ) {
$previous = get_post_meta( $post_id, '_elementor_data', true );
if ( empty( $previous ) ) {
return;
}
update_post_meta(
$post_id,
self::UNDO_SNAPSHOT_META,
array(
'data' => $previous,
'timestamp' => time(),
'user' => get_current_user_id(),
)
);
}
/**
* Sanitize raw-HTML-bearing widget settings in an element tree.
*
* The `html`, `shortcode`, and `text-editor` widgets accept markup that is
* rendered verbatim on the frontend. When a user without the `unfiltered_html`
* capability supplies these (e.g. via an AI/MCP client), run their content
* through `wp_kses_post()` to strip scripts and other dangerous markup,
* mirroring Elementor's own capability gate.
*
* @since 1.45.0
*
* @param mixed $elements Element tree (array of element nodes).
* @return mixed Sanitized element tree.
*/
public static function sanitize_raw_html_widgets( $elements ) {
if ( ! is_array( $elements ) ) {
return $elements;
}
// Map of widgetType => setting key that holds raw markup.
$raw_html_widgets = array(
'html' => 'html',
'shortcode' => 'shortcode',
'text-editor' => 'editor',
);
foreach ( $elements as &$element ) {
if ( ! is_array( $element ) ) {
continue;
}
$widget_type = $element['widgetType'] ?? '';
if ( isset( $raw_html_widgets[ $widget_type ] ) ) {
$setting_key = $raw_html_widgets[ $widget_type ];
if ( isset( $element['settings'][ $setting_key ] ) && is_string( $element['settings'][ $setting_key ] ) ) {
$element['settings'][ $setting_key ] = wp_kses_post( $element['settings'][ $setting_key ] );
}
}
// Strip XSS vectors from generic settings (event-handler custom
// attributes, attribute-breaking CSS classes, markup in custom CSS) at
// any depth — these apply to every element type, not just the raw-HTML
// widgets above, and the direct-meta save below bypasses Elementor's
// own gate.
if ( isset( $element['settings'] ) && is_array( $element['settings'] ) ) {
$element['settings'] = self::sanitize_unsafe_settings( $element['settings'] );
}
// Recurse into nested children (containers, sections, columns).
if ( ! empty( $element['elements'] ) && is_array( $element['elements'] ) ) {
$element['elements'] = self::sanitize_raw_html_widgets( $element['elements'] );
}
}
unset( $element );
return $elements;
}
/**
* Recursively neutralize XSS-prone Elementor setting values.
*
* Runs only for users lacking `unfiltered_html`. Targets settings that can
* carry executable markup regardless of widget type — custom HTML attributes
* (event handlers, javascript: URLs), custom CSS classes (attribute breakout),
* and custom CSS blocks (</style> breakout) — at any nesting depth, e.g.
* link.custom_attributes inside a button or icon widget.
*
* @since 1.45.0
*
* @param mixed $node Settings array (or a nested value).
* @return mixed Sanitized settings.
*/
public static function sanitize_unsafe_settings( $node ) {
if ( ! is_array( $node ) ) {
return $node;
}
foreach ( $node as $key => $value ) {
if ( is_array( $value ) ) {
$node[ $key ] = self::sanitize_unsafe_settings( $value );
} elseif ( is_string( $value ) ) {
$node[ $key ] = self::sanitize_unsafe_setting_value( (string) $key, $value );
}
}
return $node;
}
/**
* Sanitize a single setting value, by key, for known XSS vectors.
*
* @since 1.45.0
*
* @param string $key Setting key.
* @param string $value Setting value.
* @return string Sanitized value.
*/
private static function sanitize_unsafe_setting_value( $key, $value ) {
// Custom HTML attributes — Elementor stores "key|value" per line. Drop
// event-handler attributes (on*) and other executable attributes, and
// blank out javascript:/data:/vbscript: URLs in the value.
if ( 'custom_attributes' === $key ) {
$lines = preg_split( '/[\r\n]+/', $value );
$safe = array();
foreach ( (array) $lines as $line ) {
$parts = explode( '|', $line, 2 );
$attr = isset( $parts[0] ) ? strtolower( trim( $parts[0] ) ) : '';
if ( '' === $attr ) {
continue;
}
if ( 0 === strpos( $attr, 'on' ) || in_array( $attr, array( 'style', 'srcdoc', 'formaction', 'href', 'xlink:href' ), true ) ) {
continue;
}
$val = isset( $parts[1] ) ? trim( $parts[1] ) : '';
if ( preg_match( '#^\s*(javascript|data|vbscript)\s*:#i', $val ) ) {
$val = '';
}
$safe[] = ( '' !== $val ) ? $attr . '|' . $val : $attr;
}
return implode( "\n", $safe );
}
// Custom CSS class list — keep only valid class-name characters so a value
// cannot break out of the class="" attribute.
if ( '_css_classes' === $key ) {
return preg_replace( '/[^A-Za-z0-9_\- ]/', '', $value );
}
// Custom CSS blocks (Pro) — CSS must never contain markup; strip angle
// brackets to prevent a </style> breakout.
if ( 'custom_css' === $key || 'section_custom_css' === $key || ( strlen( $key ) > 11 && '_custom_css' === substr( $key, -11 ) ) ) {
return str_replace( array( '<', '>' ), '', $value );
}
return $value;
}
/**
* Clear Elementor CSS cache for a template and force regeneration.
*
* Deletes the post-level CSS meta, triggers a global cache flush,
* and force-regenerates the post CSS file so the frontend immediately
* reflects changes without requiring a manual "Clear Files & Data".
*
* @param int $post_id Template post ID.
* @return void
*/
public static function clear_elementor_cache( $post_id ) {
delete_post_meta( $post_id, '_elementor_css' );
if ( ! class_exists( '\Elementor\Plugin' ) ) {
return;
}
// Clear global Elementor file cache.
\Elementor\Plugin::$instance->files_manager->clear_cache();
// Force-regenerate the post CSS file immediately.
if ( class_exists( '\Elementor\Core\Files\CSS\Post' ) ) {
$css_file = \Elementor\Core\Files\CSS\Post::create( $post_id );
$css_file->update();
}
// Also regenerate global CSS (for global widgets/styles).
if ( class_exists( '\Elementor\Core\Files\CSS\Global_CSS' ) ) {
$global_css = \Elementor\Core\Files\CSS\Global_CSS::create( 'global.css' );
$global_css->update();
}
}
/**
* Find an element by ID in the element tree (recursive).
*
* @param array $elements Element tree array.
* @param string $element_id Target element ID.
* @return array|null Found element or null.
*/
public static function find_element( $elements, $element_id ) {
foreach ( $elements as $element ) {
if ( isset( $element['id'] ) && $element['id'] === $element_id ) {
return $element;
}
if ( ! empty( $element['elements'] ) ) {
$found = self::find_element( $element['elements'], $element_id );
if ( null !== $found ) {
return $found;
}
}
}
return null;
}
/**
* Find the parent element and child index for a given element ID.
*
* Returns by reference so callers can mutate the parent's elements array.
*
* @param array $elements Element tree (passed by reference).
* @param string $element_id Target element ID.
* @return array|null Array with 'parent' (reference) and 'index', or null.
*/
public static function find_element_parent( &$elements, $element_id ) {
foreach ( $elements as $index => &$element ) {
if ( isset( $element['id'] ) && $element['id'] === $element_id ) {
// Element is at this level — return the containing array info.
return array(
'parent' => &$elements,
'index' => $index,
);
}
if ( ! empty( $element['elements'] ) ) {
$found = self::find_element_parent( $element['elements'], $element_id );
if ( null !== $found ) {
return $found;
}
}
}
return null;
}
/**
* Insert an element into the tree at a specified position.
*
* Positions:
* - 'append' → append to first top-level container
* - 'prepend' → prepend to first top-level container
* - [ 'after' => id ] → insert after sibling element
* - [ 'before' => id ] → insert before sibling element
* - [ 'inside' => id ] → append inside a container element
*
* @param array $elements Element tree (modified in place).
* @param array $new_element New element to insert.
* @param mixed $position Position descriptor.
* @return array|WP_Error Modified element tree or error.
*/
public static function insert_element( $elements, $new_element, $position = 'append' ) {
// Handle append/prepend to first widget-level container.
if ( 'append' === $position || 'prepend' === $position ) {
$path = self::find_widget_container( $elements );
if ( null === $path ) {
// No wrapper exists — create appropriate structure for the site.
$wrapper = self::create_wrapper( array( $new_element ) );
$elements[] = $wrapper;
return $elements;
}
// Walk the index path to reach the target container's children.
$target = &$elements;
foreach ( $path as $idx ) {
$target = &$target[ $idx ]['elements'];
}
if ( 'append' === $position ) {
$target[] = $new_element;
} else {
array_unshift( $target, $new_element );
}
return $elements;
}
// Handle numeric index positioning (e.g., position: 1 = insert at index 1).
if ( is_numeric( $position ) ) {
$index = (int) $position;
$path = self::find_widget_container( $elements );
if ( null === $path ) {
$wrapper = self::create_wrapper( array( $new_element ) );
$elements[] = $wrapper;
return $elements;
}
$target = &$elements;
foreach ( $path as $idx ) {
$target = &$target[ $idx ]['elements'];
}
// Clamp index to valid range.
$max = count( $target );
if ( $index < 0 ) {
$index = 0;
} elseif ( $index > $max ) {
$index = $max;
}
array_splice( $target, $index, 0, array( $new_element ) );
return $elements;
}
// Handle relative positioning (after/before/inside).
if ( is_array( $position ) ) {
if ( isset( $position['after'] ) ) {
return self::insert_relative( $elements, $new_element, $position['after'], 'after' );
}
if ( isset( $position['before'] ) ) {
return self::insert_relative( $elements, $new_element, $position['before'], 'before' );
}
if ( isset( $position['inside'] ) ) {
return self::insert_inside( $elements, $new_element, $position['inside'] );
}
}
return new \WP_Error(
'uael_invalid_position',
__( 'Invalid position. Use "append", "prepend", or an object with "after", "before", or "inside" key.', 'uael' ),
array( 'status' => 400 )
);
}
/**
* Remove an element from the tree by ID.
*
* @param array $elements Element tree.
* @param string $element_id Element ID to remove.
* @return array|WP_Error Modified tree or error if not found.
*/
public static function remove_element( $elements, $element_id ) {
$result = self::remove_element_recursive( $elements, $element_id );
if ( ! $result['found'] ) {
return new \WP_Error(
'uael_element_not_found',
__( 'Element not found in template.', 'uael' ),
array( 'status' => 404 )
);
}
return $result['elements'];
}
/**
* Move an element to a new position within the tree.
*
* Removes the element from its current location and inserts at the new position.
*
* @param array $elements Element tree.
* @param string $element_id Element ID to move.
* @param mixed $position New position descriptor (same as insert_element).
* @return array|WP_Error Modified tree or error.
*/
public static function move_element( $elements, $element_id, $position ) {
// Find and extract the element first.
$element = self::find_element( $elements, $element_id );
if ( null === $element ) {
return new \WP_Error(
'uael_element_not_found',
__( 'Element not found in template.', 'uael' ),
array( 'status' => 404 )
);
}
// Remove from current position.
$elements = self::remove_element( $elements, $element_id );
if ( is_wp_error( $elements ) ) {
return $elements;
}
// Insert at new position.
return self::insert_element( $elements, $element, $position );
}
/**
* Generate a random Elementor-compatible element ID.
*
* Elementor uses 7-8 character hex strings.
*
* @return string Random hex ID.
*/
public static function generate_element_id() {
return substr( md5( wp_generate_uuid4() ), 0, 7 );
}
/**
* Build a widget element array.
*
* @param string $widget_type Elementor widget type slug.
* @param array $settings Widget settings.
* @return array Elementor widget element.
*/
public static function build_widget_element( $widget_type, $settings = array() ) {
return array(
'id' => self::generate_element_id(),
'elType' => 'widget',
'widgetType' => $widget_type,
'settings' => ! empty( $settings ) ? self::normalize_elementor_settings( $settings ) : new \stdClass(),
'elements' => array(),
);
}
/**
* Create the appropriate wrapper structure for widgets.
*
* Detects whether the site uses modern containers or legacy section/column
* and creates the correct wrapper so Elementor can render the widgets.
*
* @param array $children Child widget elements.
* @return array Elementor wrapper element (container or section>column).
*/
public static function create_wrapper( $children = array() ) {
if ( self::is_container_active() ) {
return self::create_container( $children );
}
return self::create_section_column( $children );
}
/**
* Create a container element wrapping child elements.
*
* @param array $children Child elements.
* @return array Elementor container element.
*/
public static function create_container( $children = array() ) {
return array(
'id' => self::generate_element_id(),
'elType' => 'container',
'isInner' => false,
'settings' => array(
'container_type' => 'flex',
'content_width' => 'full',
),
'elements' => $children,
);
}
/**
* Create a section > column wrapper for legacy Elementor layouts.
*
* @param array $children Child widget elements.
* @return array Elementor section element containing a column.
*/
public static function create_section_column( $children = array() ) {
return array(
'id' => self::generate_element_id(),
'elType' => 'section',
'settings' => array(
'layout' => 'full_width',
'structure' => '10',
),
'elements' => array(
array(
'id' => self::generate_element_id(),
'elType' => 'column',
'settings' => array(
'_column_size' => 100,
'_inline_size' => 100,
),
'elements' => $children,
),
),
);
}
/**
* Check if the Elementor container (flexbox) experiment is active.
*
* @return bool True if containers are active, false for legacy section/column.
*/
/**
* Ensure Elementor widgets are fully registered.
*
* In WP-CLI/REST/MCP contexts, widget registration may not complete before
* abilities execute. This forces Elementor to fire its registration hooks
* so HFE and third-party widgets are available for schema introspection.
*
* @return void
*/
public static function ensure_widgets_registered() {
static $ensured = false;
if ( $ensured || ! class_exists( '\Elementor\Plugin' ) ) {
return;
}
$ensured = true;
// Force Elementor to initialize widgets if not already done.
// get_widget_types(null) triggers init_widgets() which fires 'elementor/widgets/register'.
\Elementor\Plugin::$instance->widgets_manager->get_widget_types();
}
/**
* Check if the Elementor container (flexbox) experiment is active.
*
* @return bool True if containers are active, false for legacy section/column.
*/
public static function is_container_active() {
if ( ! class_exists( '\Elementor\Plugin' ) ) {
return false;
}
$experiments = \Elementor\Plugin::$instance->experiments;
if ( ! $experiments ) {
return false;
}
return $experiments->is_feature_active( 'container' );
}
/**
* Create a section element with the specified number of columns.
*
* For legacy Elementor layouts: creates a section with N columns
* at the given size ratios. Supports outer and inner sections.
*
* @param array $column_sizes Array of column size percentages (must sum to 100).
* @param bool $is_inner Whether this is an inner section.
* @param array $settings Optional section settings.
* @return array Elementor section element with columns.
*/
public static function create_section( $column_sizes = array( 100 ), $is_inner = false, $settings = array() ) {
$columns = array();
foreach ( $column_sizes as $size ) {
$columns[] = array(
'id' => self::generate_element_id(),
'elType' => 'column',
'settings' => array(
'_column_size' => absint( $size ),
'_inline_size' => (float) $size,
),
'elements' => array(),
);
}
$structure_code = self::get_structure_preset( $column_sizes );
$section = array(
'id' => self::generate_element_id(),
'elType' => 'section',
'settings' => self::normalize_elementor_settings(
array_merge(
array(
'layout' => 'full_width',
'structure' => $structure_code,
),
$settings
)
),
'elements' => $columns,
);
if ( $is_inner ) {
$section['isInner'] = true;
}
return $section;
}
/**
* Get the Elementor structure preset code for a column layout.
*
* Elementor uses preset codes to describe column layouts in the editor:
* 10 = 1 column, 20 = 2 equal (50/50), 21 = 33/66, 22 = 66/33,
* 30 = 3 equal (33/33/33), 31 = 50/25/25, 32 = 25/50/25, 33 = 25/25/50,
* 40 = 4 equal (25x4).
*
* Falls back to "{N}0" (e.g., "50" for 5 columns) for non-standard layouts.
*
* @param array $column_sizes Array of column size percentages.
* @return string Structure preset code.
*/
public static function get_structure_preset( $column_sizes ) {
$count = count( $column_sizes );
// Standard presets by column count and sizes.
$presets = array(
1 => array( '10' ),
2 => array(
'20' => array( 50, 50 ),
'21' => array( 33, 66 ),
'22' => array( 66, 33 ),
),
3 => array(
'30' => array( 33, 34, 33 ),
'31' => array( 50, 25, 25 ),
'32' => array( 25, 50, 25 ),
'33' => array( 25, 25, 50 ),
),
4 => array(
'40' => array( 25, 25, 25, 25 ),
),
);
if ( 1 === $count ) {
return '10';
}
if ( isset( $presets[ $count ] ) && is_array( $presets[ $count ] ) ) {
foreach ( $presets[ $count ] as $code => $sizes ) {
if ( array_map( 'absint', $column_sizes ) === $sizes ) {
return (string) $code;
}
}
// No exact match — return the first preset for this column count.
$codes = array_keys( $presets[ $count ] );
return (string) $codes[0];
}
// Fallback for 5+ columns.
return (string) ( $count * 10 );
}
/**
* Create the appropriate structural layout.
*
* For legacy sites: creates section with columns.
* For container sites: creates container (optionally with nested containers for multi-column).
*
* @param array $column_sizes Column size ratios (e.g., [50, 50] or [33, 34, 33]).
* @param bool $is_inner For legacy: inner section. For containers: nested container.
* @param array $settings Optional section/container settings.
* @return array Elementor structural element.
*/
public static function create_layout( $column_sizes = array( 100 ), $is_inner = false, $settings = array() ) {
// Auto-normalize column sizes to sum to 100.
$column_sizes = self::normalize_column_sizes( $column_sizes );
if ( self::is_container_active() ) {
// Container mode: single-column — widgets go directly into this container.
if ( count( $column_sizes ) <= 1 ) {
return array(
'id' => self::generate_element_id(),
'elType' => 'container',
'isInner' => $is_inner,
'settings' => self::normalize_elementor_settings(
array_merge(
array(
'container_type' => 'flex',
'content_width' => 'full',
'flex_direction' => 'column',
),
$settings
)
),
'elements' => array(),
);
}
// Container mode: multi-column — child containers for each column.
$children = array();
foreach ( $column_sizes as $size ) {
$children[] = array(
'id' => self::generate_element_id(),
'elType' => 'container',
'isInner' => true,
'settings' => array(
'container_type' => 'flex',
'content_width' => 'full',
'flex_direction' => 'column',
'width' => array(
'size' => (float) $size,
'unit' => '%',
),
),
'elements' => array(),
);
}
return array(
'id' => self::generate_element_id(),
'elType' => 'container',
'isInner' => $is_inner,
'settings' => self::normalize_elementor_settings(
array_merge(
array(
'container_type' => 'flex',
'content_width' => 'full',
'flex_direction' => 'row',
),
$settings
)
),
'elements' => $children,
);
}
// Legacy mode: create section with columns.
return self::create_section( $column_sizes, $is_inner, $settings );
}
/**
* Normalize column size percentages to sum to 100.
*
* Handles cases where AI passes sizes that don't sum correctly
* (e.g., [33, 33, 33] = 99, or [40, 40, 40] = 120).
*
* @param array $column_sizes Raw column size percentages.
* @return array Normalized sizes summing to 100.
*/
public static function normalize_column_sizes( $column_sizes ) {
if ( empty( $column_sizes ) ) {
return array( 100 );
}
$column_sizes = array_map( 'absint', $column_sizes );
$sum = array_sum( $column_sizes );
if ( 0 === $sum ) {
// All zeros — distribute evenly.
$count = count( $column_sizes );
$each = intval( 100 / $count );
$sizes = array_fill( 0, $count, $each );
// Give remainder to the last column.
$sizes[ $count - 1 ] += 100 - ( $each * $count );
return $sizes;
}
if ( 100 === $sum ) {
return $column_sizes;
}
// Scale proportionally to sum to 100.
$scaled = array();
$total = 0;
$count = count( $column_sizes );
for ( $i = 0; $i < $count - 1; $i++ ) {
$scaled[ $i ] = max( 1, intval( round( $column_sizes[ $i ] * 100 / $sum ) ) );
$total += $scaled[ $i ];
}
// Last column gets the remainder to guarantee sum = 100.
$scaled[ $count - 1 ] = 100 - $total;
return $scaled;
}
/**
* Build a section/container element from a declarative definition.
*
* Converts a JSON structure definition into Elementor elements. Used by
* both the template builder and page builder abilities.
*
* @param array $definition Section definition with type, columns, settings, children.
* @return array|WP_Error Array with 'element' and 'count', or error.
*/
public static function build_section_from_definition( $definition ) {
$type = $definition['type'] ?? 'section';
$settings = isset( $definition['settings'] ) && is_array( $definition['settings'] ) ? $definition['settings'] : array();
$children = isset( $definition['children'] ) && is_array( $definition['children'] ) ? $definition['children'] : array();
$columns = isset( $definition['columns'] ) && is_array( $definition['columns'] ) ? array_map( 'absint', $definition['columns'] ) : array( 100 );
$column_settings = isset( $definition['column_settings'] ) && is_array( $definition['column_settings'] ) ? $definition['column_settings'] : array();
$is_inner = ! empty( $definition['is_inner'] );
$count = 1; // Count this section/container.
// Build child widget elements.
$widget_elements = array();
foreach ( $children as $child_def ) {
$child_type = $child_def['type'] ?? '';
if ( 'widget' === $child_type ) {
$widget_type = sanitize_text_field( $child_def['widget_type'] ?? '' );
$widget_settings = isset( $child_def['settings'] ) && is_array( $child_def['settings'] ) ? $child_def['settings'] : array();
if ( empty( $widget_type ) ) {
continue;
}
if ( ! self::is_widget_allowed( $widget_type ) ) {
// Check if it requires a specific plugin (e.g., UAE Pro).
$requirements = self::check_widget_requirements( $widget_type );
if ( ! empty( $requirements['required_plugin'] ) && ! $requirements['is_active'] ) {
return new \WP_Error(
'uael_widget_requires_plugin',
/* translators: %s: Required plugin name */
sprintf( __( 'This widget requires %s.', 'uael' ), $requirements['required_plugin'] ),
array( 'status' => 400 )
);
}
return new \WP_Error(
'uael_widget_not_allowed',
/* translators: %s: Widget type slug */
sprintf( __( 'Widget type "%s" is not recognized. Use template-builder/list-widget-types to see available types.', 'uael' ), $widget_type ),
array( 'status' => 400 )
);
}
$widget_elements[] = self::build_widget_element( $widget_type, $widget_settings );
++$count;
} elseif ( 'section' === $child_type || 'container' === $child_type ) {
// Nested section/container — recurse.
$nested = self::build_section_from_definition( $child_def );
if ( is_wp_error( $nested ) ) {
return $nested;
}
$widget_elements[] = $nested['element'];
$count += $nested['count'];
}
}
// Create the structural layout.
$layout = self::create_layout( $columns, $is_inner, $settings );
$is_single_col = ( count( $columns ) <= 1 );
$is_container = self::is_container_active();
// Count child columns/containers (single-column container has none).
if ( ! ( $is_container && $is_single_col ) ) {
$count += count( $columns );
}
// Apply per-column settings (alignment, padding, background, etc.).
// For single-column container: column_settings[0] merges into the parent container itself.
if ( ! empty( $column_settings ) ) {
if ( $is_container && $is_single_col ) {
if ( isset( $column_settings[0] ) && is_array( $column_settings[0] ) ) {
$normalized = self::normalize_elementor_settings( $column_settings[0] );
$layout['settings'] = array_merge( $layout['settings'], $normalized );
}
} elseif ( ! empty( $layout['elements'] ) ) {
foreach ( $layout['elements'] as $ci => &$col_element ) {
if ( isset( $column_settings[ $ci ] ) && is_array( $column_settings[ $ci ] ) ) {
$normalized = self::normalize_elementor_settings( $column_settings[ $ci ] );
$col_element['settings'] = array_merge( $col_element['settings'], $normalized );
}
}
unset( $col_element );
}
}
// Distribute widgets across columns/containers.
if ( ! empty( $widget_elements ) ) {
if ( $is_container && $is_single_col ) {
// Single-column container: widgets go directly into the parent container.
$layout['elements'] = array_merge( $layout['elements'], $widget_elements );
} elseif ( ! empty( $layout['elements'] ) ) {
$col_count = count( $layout['elements'] );
foreach ( $widget_elements as $wi => $widget ) {
$col_index = min( $wi, $col_count - 1 );
$layout['elements'][ $col_index ]['elements'][] = $widget;
}
}
}
return array(
'element' => $layout,
'count' => $count,
);
}
/**
* Add a column to an existing section element in the tree.
*
* Recalculates all column sizes to distribute evenly if auto_resize is true.
*
* @param array $elements Element tree.
* @param string $section_id Section element ID to add column to.
* @param int $column_size Column size percentage (ignored if auto_resize).
* @param bool $auto_resize Whether to auto-distribute column sizes.
* @return array|WP_Error Modified tree or error.
*/
public static function add_column_to_section( $elements, $section_id, $column_size = 0, $auto_resize = true ) {
$result = self::add_column_recursive( $elements, $section_id, $column_size, $auto_resize );
if ( ! $result['found'] ) {
return new \WP_Error(
'uael_section_not_found',
/* translators: %s: element ID */
sprintf( __( 'Section "%s" not found in template.', 'uael' ), sanitize_text_field( $section_id ) ),
array( 'status' => 404 )
);
}
return $result;
}
/**
* Recursive helper for adding a column to a section.
*
* @param array $elements Element tree.
* @param string $section_id Target section ID.
* @param int $column_size Column size percentage.
* @param bool $auto_resize Auto-distribute sizes.
* @return array Result with 'found', 'elements', and 'column_id'.
*/
private static function add_column_recursive( $elements, $section_id, $column_size, $auto_resize ) {
foreach ( $elements as $index => $element ) {
if ( isset( $element['id'] ) && $element['id'] === $section_id ) {
$el_type = $element['elType'] ?? '';
if ( 'section' !== $el_type && 'container' !== $el_type ) {
return array(
'found' => false,
'elements' => $elements,
);
}
$new_column_id = self::generate_element_id();
if ( 'container' === $el_type ) {
// Container mode: add a child container.
$new_col = array(
'id' => $new_column_id,
'elType' => 'container',
'isInner' => true,
'settings' => array(
'container_type' => 'flex',
'content_width' => 'full',
),
'elements' => array(),
);
} else {
// Section mode: add a column.
$new_col = array(
'id' => $new_column_id,
'elType' => 'column',
'settings' => array(
'_column_size' => $column_size,
'_inline_size' => (float) $column_size,
),
'elements' => array(),
);
}
$elements[ $index ]['elements'][] = $new_col;
// Auto-resize all columns/containers to distribute evenly.
if ( $auto_resize ) {
$col_count = count( $elements[ $index ]['elements'] );
$each_size = intval( 100 / $col_count );
if ( 'section' === $el_type ) {
foreach ( $elements[ $index ]['elements'] as $ci => &$col ) {
$col['settings']['_column_size'] = $each_size;
$col['settings']['_inline_size'] = (float) $each_size;
}
unset( $col );
// Update the section's structure preset.
$new_sizes = array_fill( 0, $col_count, $each_size );
$elements[ $index ]['settings']['structure'] = self::get_structure_preset( $new_sizes );
} else {
// Container mode: update child container widths.
foreach ( $elements[ $index ]['elements'] as $ci => &$col ) {
$col['settings']['width'] = array(
'size' => (float) $each_size,
'unit' => '%',
);
}
unset( $col );
}
}
return array(
'found' => true,
'elements' => $elements,
'column_id' => $new_column_id,
);
}
if ( ! empty( $element['elements'] ) ) {
$child_result = self::add_column_recursive( $element['elements'], $section_id, $column_size, $auto_resize );
if ( $child_result['found'] ) {
$elements[ $index ]['elements'] = $child_result['elements'];
return array(
'found' => true,
'elements' => $elements,
'column_id' => $child_result['column_id'],
);
}
}
}
return array(
'found' => false,
'elements' => $elements,
);
}
/**
* Simplify element tree for API response (strip heavy settings, keep structure).
*
* Returns a clean representation with id, type, widgetType, and children.
* Includes a flattened list of setting keys (not values) to hint at configuration.
*
* @param array $elements Element tree.
* @return array Simplified tree.
*/
public static function simplify_tree( $elements ) {
$result = array();
foreach ( $elements as $element ) {
$item = array(
'id' => $element['id'] ?? '',
'elType' => $element['elType'] ?? '',
);
if ( ! empty( $element['widgetType'] ) ) {
$item['widgetType'] = $element['widgetType'];
}
if ( ! empty( $element['settings'] ) && is_array( $element['settings'] ) ) {
// Include only non-default settings keys for brevity.
$setting_keys = array_keys( $element['settings'] );
if ( ! empty( $setting_keys ) ) {
$item['setting_keys'] = $setting_keys;
}
}
if ( ! empty( $element['elements'] ) ) {
$item['elements'] = self::simplify_tree( $element['elements'] );
} else {
$item['elements'] = array();
}
$result[] = $item;
}
return $result;
}
// ──────────────────────────────────────────────
// Private helpers.
// ──────────────────────────────────────────────
/**
* Recursively convert stdClass objects to arrays in element data.
*
* Elementor's Document save API expects arrays for settings, but
* build_widget_element() uses stdClass for empty settings to ensure
* correct JSON encoding ({} instead of []).
*
* @param mixed $data Element tree data (array, stdClass, or scalar).
* @return mixed Converted data with all stdClass replaced by arrays.
*/
private static function convert_stdclass_to_array( $data ) {
if ( $data instanceof \stdClass ) {
$data = (array) $data;
}
if ( is_array( $data ) ) {
foreach ( $data as $key => $value ) {
$data[ $key ] = self::convert_stdclass_to_array( $value );
}
}
return $data;
}
/**
* Find the index path to the innermost container that can hold widgets.
*
* Recursively descends through the first structural child (section, column,
* or container) until it reaches a level where children are widgets or empty.
* Works with both legacy layouts (section > column > widget) and modern
* container layouts (container > container > widget) at any nesting depth.
*
* @param array $elements Element tree at current level.
* @return array|null Ordered array of integer indices forming the path, or null.
*/
private static function find_widget_container( $elements ) {
$container_types = array( 'section', 'column', 'container' );
foreach ( $elements as $index => $element ) {
$type = $element['elType'] ?? '';
if ( ! in_array( $type, $container_types, true ) ) {
continue;
}
// Found a structural element. Check if its children are also structural.
if ( ! empty( $element['elements'] ) ) {
$first_child_type = $element['elements'][0]['elType'] ?? '';
if ( in_array( $first_child_type, $container_types, true ) ) {
// Children are structural — descend deeper.
$deeper = self::find_widget_container( $element['elements'] );
if ( null !== $deeper ) {
return array_merge( array( $index ), $deeper );
}
}
}
// Children are widgets or empty — this is the widget-level container.
return array( $index );
}
return null;
}
/**
* Insert element relative to a sibling (after or before).
*
* @param array $elements Element tree.
* @param array $new_element New element.
* @param string $sibling_id Sibling element ID.
* @param string $direction 'after' or 'before'.
* @return array|WP_Error Modified tree or error.
*/
private static function insert_relative( $elements, $new_element, $sibling_id, $direction ) {
$result = self::insert_relative_recursive( $elements, $new_element, $sibling_id, $direction );
if ( ! $result['found'] ) {
return new \WP_Error(
'uael_sibling_not_found',
/* translators: %s: element ID */
sprintf( __( 'Sibling element "%s" not found in template.', 'uael' ), sanitize_text_field( $sibling_id ) ),
array( 'status' => 404 )
);
}
return $result['elements'];
}
/**
* Recursive helper for relative insertion.
*
* @param array $elements Element tree.
* @param array $new_element New element.
* @param string $sibling_id Sibling element ID.
* @param string $direction 'after' or 'before'.
* @return array Result with 'found' bool and 'elements' array.
*/
private static function insert_relative_recursive( $elements, $new_element, $sibling_id, $direction ) {
foreach ( $elements as $index => $element ) {
if ( isset( $element['id'] ) && $element['id'] === $sibling_id ) {
$offset = 'after' === $direction ? $index + 1 : $index;
array_splice( $elements, $offset, 0, array( $new_element ) );
return array(
'found' => true,
'elements' => $elements,
);
}
if ( ! empty( $element['elements'] ) ) {
$child_result = self::insert_relative_recursive( $element['elements'], $new_element, $sibling_id, $direction );
if ( $child_result['found'] ) {
$elements[ $index ]['elements'] = $child_result['elements'];
return array(
'found' => true,
'elements' => $elements,
);
}
}
}
return array(
'found' => false,
'elements' => $elements,
);
}
/**
* Insert element inside a container.
*
* @param array $elements Element tree.
* @param array $new_element New element.
* @param string $parent_id Parent container ID.
* @return array|WP_Error Modified tree or error.
*/
private static function insert_inside( $elements, $new_element, $parent_id ) {
$result = self::insert_inside_recursive( $elements, $new_element, $parent_id );
if ( ! $result['found'] ) {
return new \WP_Error(
'uael_parent_not_found',
/* translators: %s: element ID */
sprintf( __( 'Parent element "%s" not found in template.', 'uael' ), sanitize_text_field( $parent_id ) ),
array( 'status' => 404 )
);
}
return $result['elements'];
}
/**
* Recursive helper for inserting inside a container.
*
* @param array $elements Element tree.
* @param array $new_element New element.
* @param string $parent_id Parent container ID.
* @return array Result with 'found' bool and 'elements' array.
*/
private static function insert_inside_recursive( $elements, $new_element, $parent_id ) {
foreach ( $elements as $index => $element ) {
if ( isset( $element['id'] ) && $element['id'] === $parent_id ) {
if ( ! isset( $elements[ $index ]['elements'] ) ) {
$elements[ $index ]['elements'] = array();
}
$elements[ $index ]['elements'][] = $new_element;
return array(
'found' => true,
'elements' => $elements,
);
}
if ( ! empty( $element['elements'] ) ) {
$child_result = self::insert_inside_recursive( $element['elements'], $new_element, $parent_id );
if ( $child_result['found'] ) {
$elements[ $index ]['elements'] = $child_result['elements'];
return array(
'found' => true,
'elements' => $elements,
);
}
}
}
return array(
'found' => false,
'elements' => $elements,
);
}
/**
* Recursive helper for element removal.
*
* @param array $elements Element tree.
* @param string $element_id Element ID to remove.
* @return array Result with 'found' bool and 'elements' array.
*/
private static function remove_element_recursive( $elements, $element_id ) {
foreach ( $elements as $index => $element ) {
if ( isset( $element['id'] ) && $element['id'] === $element_id ) {
array_splice( $elements, $index, 1 );
return array(
'found' => true,
'elements' => $elements,
);
}
if ( ! empty( $element['elements'] ) ) {
$child_result = self::remove_element_recursive( $element['elements'], $element_id );
if ( $child_result['found'] ) {
$elements[ $index ]['elements'] = $child_result['elements'];
return array(
'found' => true,
'elements' => $elements,
);
}
}
}
return array(
'found' => false,
'elements' => $elements,
);
}
// ──────────────────────────────────────────────
// Widget schema generation.
// ──────────────────────────────────────────────
/**
* Generate a JSON-compatible settings schema for a widget type.
*
* Introspects Elementor's registered controls for the widget and returns
* a schema describing the content-relevant settings an MCP client can set.
* Filters out injected controls (motion effects, transforms, display
* conditions, promos) to keep the schema focused on widget-owned settings.
*
* @param string $widget_type Widget type slug.
* @param string $tab Which tab to include: 'content', 'style', or 'all'. Default 'content'.
* @return array|WP_Error Schema array or error if widget not found.
*/
public static function get_widget_schema( $widget_type, $tab = 'content' ) {
if ( ! class_exists( '\Elementor\Plugin' ) ) {
return new \WP_Error(
'uael_elementor_not_active',
__( 'Elementor is not active.', 'uael' ),
array( 'status' => 500 )
);
}
// Ensure Elementor widgets are fully initialized.
// In WP-CLI/REST contexts, widget registration may not have completed yet.
self::ensure_widgets_registered();
$widget = \Elementor\Plugin::$instance->widgets_manager->get_widget_types( $widget_type );
if ( ! $widget ) {
return new \WP_Error(
'uael_widget_not_found',
/* translators: %s: widget type slug */
sprintf( __( 'Widget type "%s" not found in Elementor.', 'uael' ), sanitize_text_field( $widget_type ) ),
array( 'status' => 404 )
);
}
$controls = $widget->get_controls();
$properties = array();
// Sections injected by Elementor core or third-party plugins (not widget-owned).
$injected_sections = array(
'section_effects',
'_section_transform',
'_section_background',
'_section_border',
'_section_masking',
'_section_responsive',
'_section_attributes',
'_section_style',
'section_custom_css',
);
// Structural control types that don't map to settings.
$skip_types = array( 'section', 'tabs', 'tab', 'divider', 'raw_html', 'heading', 'deprecated_notice', 'alert', 'notice' );
foreach ( $controls as $key => $ctrl ) {
// Skip internal controls.
if ( 0 === strpos( $key, '_' ) ) {
continue;
}
// Tab filter.
$ctrl_tab = $ctrl['tab'] ?? '';
if ( 'all' !== $tab && $ctrl_tab !== $tab ) {
continue;
}
// Skip injected sections.
$section = $ctrl['section'] ?? '';
if ( in_array( $section, $injected_sections, true ) ) {
continue;
}
// Skip promo/third-party injected sections.
if ( 0 === strpos( $section, 'uael_' ) && false !== strpos( $section, '_promo' ) ) {
continue;
}
if ( 0 === strpos( $section, 'eael_' ) ) {
continue;
}
if ( 0 === strpos( $key, 'motion_fx_' ) || 0 === strpos( $key, 'sticky' ) ) {
continue;
}
// Skip structural types.
$type = $ctrl['type'] ?? '';
if ( in_array( $type, $skip_types, true ) ) {
continue;
}
$prop = self::map_control_to_schema( $ctrl );
if ( null !== $prop ) {
$properties[ $key ] = $prop;
}
}
$result = array(
'widget_type' => $widget_type,
'title' => $widget->get_title(),
'tab' => $tab,
'schema' => array(
'type' => 'object',
'properties' => $properties,
),
);
// Include pro alternative info if available (feature-level upsell).
$pro_alt = self::get_pro_alternative( $widget_type );
if ( ! empty( $pro_alt ) ) {
$result['pro_alternative'] = $pro_alt;
}
return $result;
}
/**
* Map a single Elementor control to a JSON schema property.
*
* @param array $ctrl Elementor control definition.
* @return array|null JSON schema property or null if unmappable.
*/
private static function map_control_to_schema( $ctrl ) {
$type = $ctrl['type'] ?? '';
$label = $ctrl['label'] ?? '';
$default = $ctrl['default'] ?? null;
$prop = array();
switch ( $type ) {
case 'text':
case 'textarea':
case 'hidden':
case 'animation':
case 'hover_animation':
case 'text_shadow':
$prop['type'] = 'string';
break;
case 'number':
$prop['type'] = 'number';
break;
case 'select':
case 'choose':
$prop['type'] = 'string';
$options = $ctrl['options'] ?? array();
if ( ! empty( $options ) ) {
$prop['enum'] = array_keys( $options );
}
break;
case 'select2':
$prop['type'] = 'string';
break;
case 'switcher':
$prop['type'] = 'string';
$prop['enum'] = array( 'yes', '' );
break;
case 'color':
$prop['type'] = 'string';
$prop['description'] = 'CSS color value (hex, rgb, etc.)';
break;
case 'slider':
$prop['type'] = 'object';
$prop['properties'] = array(
'size' => array( 'type' => 'number' ),
'unit' => array(
'type' => 'string',
'default' => 'px',
),
);
break;
case 'dimensions':
$prop['type'] = 'object';
$prop['properties'] = array(
'top' => array( 'type' => 'string' ),
'right' => array( 'type' => 'string' ),
'bottom' => array( 'type' => 'string' ),
'left' => array( 'type' => 'string' ),
'unit' => array(
'type' => 'string',
'default' => 'px',
),
'isLinked' => array( 'type' => 'boolean' ),
);
break;
case 'url':
$prop['type'] = 'object';
$prop['properties'] = array(
'url' => array( 'type' => 'string' ),
'is_external' => array(
'type' => 'string',
'enum' => array( 'on', '' ),
),
'nofollow' => array(
'type' => 'string',
'enum' => array( 'on', '' ),
),
'custom_attributes' => array( 'type' => 'string' ),
);
break;
case 'media':
case 'gallery':
$prop['type'] = 'object';
$prop['properties'] = array(
'url' => array( 'type' => 'string' ),
'id' => array( 'type' => 'integer' ),
);
break;
case 'popover_toggle':
$prop['type'] = 'string';
$prop['enum'] = array( 'yes', '' );
break;
case 'icons':
$prop['type'] = 'object';
$prop['properties'] = array(
'value' => array( 'type' => 'string' ),
'library' => array( 'type' => 'string' ),
);
break;
case 'typography':
$prop['type'] = 'string';
$prop['enum'] = array( 'custom', '' );
$prop['description'] = 'Typography toggle. Set to "custom" to enable font overrides. '
. 'Then set {name}_font_family, {name}_font_size ({"size":16,"unit":"px"}), '
. '{name}_font_weight (100-900), {name}_line_height, {name}_letter_spacing. '
. 'These are auto-set by build-template/build-page when you provide the sub-keys.';
break;
case 'box_shadow':
$prop['type'] = 'string';
$prop['enum'] = array( 'yes', '' );
$prop['description'] = 'Box shadow toggle. Sub-keys: {name}_box_shadow_type, '
. '{name}_box_shadow ({"horizontal":0,"vertical":4,"blur":10,"spread":0,"color":"rgba(0,0,0,0.1)"}).';
break;
case 'border':
$prop['type'] = 'string';
$prop['enum'] = array( 'solid', 'double', 'dotted', 'dashed', 'groove', '' );
$prop['description'] = 'Border type. Sub-keys: {name}_width (dimensions), {name}_color (hex).';
break;
case 'background':
$prop['type'] = 'string';
$prop['enum'] = array( 'classic', 'gradient', '' );
$prop['description'] = 'Background type toggle. Set to "classic" then use {name}_color (hex). '
. 'For gradient: set to "gradient" then use {name}_color, {name}_color_b, {name}_gradient_angle.';
break;
case 'text_shadow':
$prop['type'] = 'string';
$prop['enum'] = array( 'yes', '' );
$prop['description'] = 'Text shadow toggle. Sub-keys: {name}_text_shadow '
. '({"horizontal":0,"vertical":2,"blur":5,"color":"rgba(0,0,0,0.3)"}).';
break;
case 'css_filter':
$prop['type'] = 'string';
$prop['enum'] = array( 'custom', '' );
$prop['description'] = 'CSS filter toggle. Sub-keys: {name}_blur, {name}_brightness, '
. '{name}_contrast, {name}_saturate, {name}_hue.';
break;
case 'text_stroke':
$prop['type'] = 'string';
$prop['enum'] = array( 'yes', '' );
$prop['description'] = 'Text stroke toggle. Sub-keys: {name}_text_stroke '
. '({"size":1,"unit":"px"}), {name}_stroke_color (hex).';
break;
case 'image_size':
$prop['type'] = 'string';
$prop['description'] = 'Image size preset (thumbnail, medium, large, full) or custom dimensions.';
break;
case 'repeater':
$prop['type'] = 'array';
$prop['description'] = 'Array of repeater items. Each item is an object with fields specific to this control. '
. 'Call get-widget-schema to see the fields for each repeater item.';
break;
default:
// Unknown type — include as string fallback.
$prop['type'] = 'string';
break;
}
// Set label as description only if we don't already have a more detailed one from group controls.
if ( ! empty( $label ) && empty( $prop['description'] ) ) {
$prop['description'] = $label;
} elseif ( ! empty( $label ) && ! empty( $prop['description'] ) ) {
// Prepend label to group control descriptions for context.
$prop['description'] = $label . '. ' . $prop['description'];
}
if ( null !== $default && '' !== $default ) {
$prop['default'] = $default;
}
// Include condition/dependency info so MCP clients know about toggle prerequisites.
if ( ! empty( $ctrl['condition'] ) ) {
$prop['depends_on'] = $ctrl['condition'];
}
// Include section grouping for context.
if ( ! empty( $ctrl['section'] ) ) {
$prop['section'] = $ctrl['section'];
}
return $prop;
}
// ──────────────────────────────────────────────
// Allowed widget types.
// ──────────────────────────────────────────────
/**
* Get all widget types allowed for insertion into HFE templates.
*
* Includes HFE free widgets (always), Elementor core widgets (always),
* and UAE Pro widgets (when active). Filterable via `uae_mcp_allowed_template_widgets`.
*
* @return array Array of widget info: [ [ 'slug' => string, 'title' => string, 'source' => string ], ... ]
*/
public static function get_allowed_widget_types() {
$widgets = array();
// Elementor core widgets — always available when Elementor is active.
$widgets = array_merge( $widgets, self::get_elementor_core_widget_types() );
// UAE Pro widgets — available when UAE Pro is active.
if ( class_exists( '\UltimateElementor\Classes\UAEL_Config' ) ) {
$widgets = array_merge( $widgets, self::get_uae_pro_widget_types() );
}
// UAE (Free / Header Footer Elementor) widgets — only when that plugin
// is active. In standalone Pro these are not registered in Elementor, so
// advertising them would offer widgets that cannot actually be inserted.
if ( defined( 'HFE_VER' ) ) {
$widgets = array_merge( $widgets, self::get_uael_widget_types() );
}
/**
* Filter the allowed widget types for template building.
*
* @since 1.45.0
*
* @param array $widgets Array of widget info arrays.
*/
return apply_filters( 'uae_mcp_allowed_template_widgets', $widgets );
}
/**
* Get allowed widget slugs as a flat array.
*
* @return array Array of widget type slugs.
*/
public static function get_allowed_widget_slugs() {
return array_column( self::get_allowed_widget_types(), 'slug' );
}
/**
* Check if a widget type is allowed for insertion.
*
* @param string $widget_type Widget type slug.
* @return bool True if allowed.
*/
/**
* Get the pro alternative info for a free widget.
*
* Returns upgrade suggestion when a free widget has a more capable
* UAE Pro equivalent with additional features.
*
* @param string $widget_type Widget type slug.
* @return array|null Pro alternative info or null.
*/
public static function get_pro_alternative( $widget_type ) {
$uael_widgets = self::get_uael_widget_types();
foreach ( $uael_widgets as $widget ) {
if ( $widget['slug'] === $widget_type && ! empty( $widget['pro_alternative'] ) ) {
return $widget['pro_alternative'];
}
}
return null;
}
/**
* Check if a widget type is allowed for insertion.
*
* @param string $widget_type Widget type slug.
* @return bool True if allowed.
*/
public static function is_widget_allowed( $widget_type ) {
return in_array( $widget_type, self::get_allowed_widget_slugs(), true );
}
/**
* Get the source/plugin name for a widget type.
*
* @param string $widget_type Widget type slug.
* @return string Source name or empty string.
*/
public static function get_widget_source( $widget_type ) {
$widgets = self::get_allowed_widget_types();
foreach ( $widgets as $widget ) {
if ( $widget['slug'] === $widget_type ) {
return $widget['source'];
}
}
return '';
}
/**
* Get HFE widget types including pro_alternative data.
*
* Public accessor for pro/features handler.
*
* @return array Widget info arrays with pro_alternative when available.
*/
public static function get_uael_widget_types_with_pro() {
return self::get_uael_widget_types();
}
/**
* Get HFE free widget types.
*
* @return array Widget info arrays.
*/
private static function get_uael_widget_types() {
// Slugs match Widgets_Config::get_widget_list() — these are the Elementor widget type names.
// pro_alternative: when the free widget can't do what the user asks, suggest the UAE Pro upgrade.
$widgets = array(
array(
'slug' => 'retina',
'title' => 'Retina Logo',
),
array(
'slug' => 'page-title',
'title' => 'Page Title',
),
array(
'slug' => 'hfe-site-tagline',
'title' => 'Site Tagline',
),
array(
'slug' => 'hfe-site-title',
'title' => 'Site Title',
),
array(
'slug' => 'post-info-widget',
'title' => 'Post Info',
),
array(
'slug' => 'hfe-basic-posts',
'title' => 'Basic Posts',
'pro_alternative' => array(
'widget' => 'uael-posts',
'plugin' => 'Ultimate Addons for Elementor Pro',
'features' => 'pagination, AJAX load more, infinite scroll, carousel, 5 layout skins, advanced query builder, post filtering UI, custom post type support',
),
),
array(
'slug' => 'hfe-breadcrumbs-widget',
'title' => 'Breadcrumbs',
),
array(
'slug' => 'hfe-cart',
'title' => 'WooCommerce Cart',
'pro_alternative' => array(
'widget' => 'uael-woo-mini-cart',
'plugin' => 'Ultimate Addons for Elementor Pro',
'features' => 'drawer animations, custom notification styling, advanced cart display options',
),
),
array(
'slug' => 'copyright',
'title' => 'Copyright',
),
array(
'slug' => 'navigation-menu',
'title' => 'Navigation Menu',
'pro_alternative' => array(
'widget' => 'uael-nav-menu',
'plugin' => 'Ultimate Addons for Elementor Pro',
'features' => 'mega menu, offcanvas mobile drawer, dropdown animations, full-width dropdowns, submenu icon customization',
),
),
array(
'slug' => 'site-logo',
'title' => 'Site Logo',
),
array(
'slug' => 'hfe-infocard',
'title' => 'Info Card',
'pro_alternative' => array(
'widget' => 'uael-infobox',
'plugin' => 'Ultimate Addons for Elementor Pro',
'features' => 'multiple icon positions (top/left/right), preset designs, advanced hover effects, number badges, gradient text, icon flip/rotation',
),
),
array(
'slug' => 'hfe-search-button',
'title' => 'Search',
'pro_alternative' => array(
'widget' => 'uael-advanced-search',
'plugin' => 'Ultimate Addons for Elementor Pro',
'features' => 'AJAX live search results, search filters, custom post type search, multiple search layouts',
),
),
array(
'slug' => 'hfe-woo-product-grid',
'title' => 'WooCommerce Product Grid',
'pro_alternative' => array(
'widget' => 'uael-woo-products',
'plugin' => 'Ultimate Addons for Elementor Pro',
'features' => 'pagination, AJAX load more, infinite scroll, carousel, quick view, sale/featured badges, advanced query filters, multiple skins',
),
),
array(
'slug' => 'hfe-counter',
'title' => 'Counter',
),
);
foreach ( $widgets as &$w ) {
$w['source'] = 'uael';
}
return $widgets;
}
/**
* Get Elementor core widget types commonly used in headers/footers.
*
* @return array Widget info arrays.
*/
private static function get_elementor_core_widget_types() {
$widgets = array(
array(
'slug' => 'heading',
'title' => 'Heading',
),
array(
'slug' => 'text-editor',
'title' => 'Text Editor',
),
array(
'slug' => 'image',
'title' => 'Image',
),
array(
'slug' => 'button',
'title' => 'Button',
),
array(
'slug' => 'icon',
'title' => 'Icon',
),
array(
'slug' => 'icon-list',
'title' => 'Icon List',
),
array(
'slug' => 'spacer',
'title' => 'Spacer',
),
array(
'slug' => 'divider',
'title' => 'Divider',
),
array(
'slug' => 'html',
'title' => 'HTML',
),
array(
'slug' => 'shortcode',
'title' => 'Shortcode',
),
array(
'slug' => 'social-icons',
'title' => 'Social Icons',
),
);
foreach ( $widgets as &$w ) {
$w['source'] = 'elementor';
}
return $widgets;
}
/**
* Get UAE Pro widget types.
*
* @return array Widget info arrays.
*/
private static function get_uae_pro_widget_types() {
if ( ! class_exists( '\UltimateElementor\Classes\UAEL_Config' ) ) {
return array();
}
$widgets = array();
$uae_widgets = \UltimateElementor\Classes\UAEL_Config::get_widget_list();
if ( ! empty( $uae_widgets ) && is_array( $uae_widgets ) ) {
foreach ( $uae_widgets as $key => $config ) {
if ( ! empty( $config['slug'] ) ) {
$widgets[] = array(
'slug' => $config['slug'],
'title' => $config['title'] ?? $key,
'source' => 'ultimate-addons-for-elementor',
);
}
}
}
return $widgets;
}
/**
* Determine which plugin a widget type requires and whether it's active.
*
* @param string $widget_type Widget type slug.
* @return array [ 'required_plugin' => string, 'is_active' => bool ]
*/
public static function check_widget_requirements( $widget_type ) {
// Check HFE widgets.
$uael_slugs = array_column( self::get_uael_widget_types(), 'slug' );
if ( in_array( $widget_type, $uael_slugs, true ) ) {
return array(
'required_plugin' => 'Header Footer Elementor',
'is_active' => true,
);
}
// Check Elementor core widgets.
$elementor_slugs = array_column( self::get_elementor_core_widget_types(), 'slug' );
if ( in_array( $widget_type, $elementor_slugs, true ) ) {
return array(
'required_plugin' => 'Elementor',
'is_active' => class_exists( '\Elementor\Plugin' ),
);
}
// Check UAE Pro widgets.
if ( 0 === strpos( $widget_type, 'uael-' ) ) {
return array(
'required_plugin' => 'Ultimate Addons for Elementor Pro',
'is_active' => class_exists( 'UAEL_Config' ),
);
}
return array(
'required_plugin' => '',
'is_active' => false,
);
}
/**
* Normalize Elementor settings by auto-setting required toggle keys.
*
* Elementor uses a pattern where feature groups must be "activated" before
* their values take effect. For example, `background_color` is ignored
* unless `background_background` is set to "classic". This method
* auto-sets those toggle keys when their dependent values are present.
*
* @param array $settings Raw settings array.
* @return array Normalized settings with toggle keys auto-set.
*/
public static function normalize_elementor_settings( $settings ) {
if ( empty( $settings ) || ! is_array( $settings ) ) {
return $settings;
}
// Background: if background_color or background_image is set, activate classic background.
if ( isset( $settings['background_color'] ) || isset( $settings['background_image'] ) ) {
if ( ! isset( $settings['background_background'] ) ) {
$settings['background_background'] = 'classic';
}
}
// Background overlay: same pattern with background_overlay_ prefix.
if ( isset( $settings['background_overlay_color'] ) || isset( $settings['background_overlay_image'] ) ) {
if ( ! isset( $settings['background_overlay_background'] ) ) {
$settings['background_overlay_background'] = 'classic';
}
}
// Border: if border_width or border_color is set, activate border.
if ( isset( $settings['border_width'] ) || isset( $settings['border_color'] ) ) {
if ( ! isset( $settings['border_border'] ) ) {
$settings['border_border'] = 'solid';
}
}
// Typography toggles: scan for *_font_family, *_font_size, *_font_weight
// and auto-set the corresponding *_typography to "custom".
foreach ( $settings as $key => $value ) {
if ( preg_match( '/^(.+)_(font_family|font_size|font_weight|line_height|letter_spacing)$/', $key, $matches ) ) {
$toggle_key = $matches[1] . '_typography';
if ( ! isset( $settings[ $toggle_key ] ) ) {
$settings[ $toggle_key ] = 'custom';
}
}
}
return $settings;
}
}