fix(elementor): port v1.9.0 data layer — save_page_data wp_slash fallback, CSS invalidation, by-ref insert/remove/update, normalize_container_settings, generate_id uses random_bytes

This commit is contained in:
2026-06-02 20:44:47 +00:00
parent 96583831bb
commit 10450b5233
+261 -277
View File
@@ -3,208 +3,206 @@ namespace AskTamMCP\Elementor\Data;
defined('ABSPATH') || exit;
/**
* Data access layer for Elementor page data.
* Elementor data access layer.
*
* Read/write strategy:
* CLI / DOING_CRON → raw $wpdb queries, bypassing WP object cache entirely.
* HTTP context → document->save() first; falls back to direct meta write
* with wp_slash() + CSS cache invalidation on failure.
*
* Methods mirror the public API of Elementor_MCP_Data (v1.9.0) so both
* HTTP and CLI callers use identical call signatures.
* Methods ported from elementor-mcp v1.9.0 (class-elementor-data.php).
* CLI/cron context uses raw $wpdb queries to bypass WP object cache —
* the core fix for stale-read bugs in long-running build scripts.
*/
class ElementorData {
// -------------------------------------------------------------------------
// Document access
// get_document
// -------------------------------------------------------------------------
public function get_document(int $post_id) {
if (!class_exists('\Elementor\Plugin')) {
return new \WP_Error('elementor_missing', 'Elementor is not installed or active.');
public function get_document( int $post_id ) {
if ( ! class_exists( '\Elementor\Plugin' ) ) {
return new \WP_Error( 'elementor_missing', 'Elementor is not installed or active.' );
}
$document = \Elementor\Plugin::$instance->documents->get($post_id);
if (!$document) {
return new \WP_Error('document_not_found', "Elementor document not found for post ID {$post_id}.");
$document = \Elementor\Plugin::$instance->documents->get( $post_id );
if ( ! $document ) {
return new \WP_Error(
'document_not_found',
sprintf( 'Elementor document not found for post ID %d.', $post_id )
);
}
return $document;
}
// -------------------------------------------------------------------------
// Read page data
// get_page_data
// Tries document API first, falls back to raw DB read for CLI/cron.
// Matches upstream v1.9.0 get_page_data() with our CLI-bypass layer.
// -------------------------------------------------------------------------
/**
* Gets the element tree for an Elementor page.
*
* CLI/cron: reads directly from $wpdb, bypassing WP object cache entirely.
* HTTP: tries document->get_elements_data(), falls back to get_post_meta(),
* then raw $wpdb as last resort (handles all proxy/REST contexts).
*/
public function get_page_data(int $post_id) {
// CLI/cron: WP object cache stays stale after meta writes done outside
// document->save(). Always read raw from DB.
if (PHP_SAPI === 'cli' || (defined('DOING_CRON') && DOING_CRON)) {
public function get_page_data( int $post_id ) {
// CLI/cron: bypass WP object cache entirely — prevents stale reads in
// long-running scripts where update_post_meta calls leave the cache
// inconsistent (the root cause of elements overwriting each other).
if ( PHP_SAPI === 'cli' || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
global $wpdb;
$raw = $wpdb->get_var($wpdb->prepare(
$raw = $wpdb->get_var( $wpdb->prepare(
"SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id=%d AND meta_key='_elementor_data' LIMIT 1",
$post_id
));
if (!empty($raw) && is_string($raw)) {
$decoded = json_decode($raw, true);
if (is_array($decoded)) return $decoded;
) );
if ( ! empty( $raw ) && is_string( $raw ) ) {
$decoded = json_decode( $raw, true );
if ( is_array( $decoded ) ) {
return $decoded;
}
}
return [];
return array();
}
// HTTP: try Elementor document API first (canonical path).
$document = $this->get_document($post_id);
if (is_wp_error($document)) return $document;
// Web context: try document API first (upstream v1.9.0 pattern).
$document = $this->get_document( $post_id );
if ( is_wp_error( $document ) ) {
return $document;
}
$data = $document->get_elements_data();
if (is_array($data) && !empty($data)) return $data;
// Fallback: raw get_post_meta (handles REST/proxy contexts).
$raw = get_post_meta($post_id, '_elementor_data', true);
if (!empty($raw) && is_string($raw)) {
$decoded = json_decode($raw, true);
if (is_array($decoded)) return $decoded;
if ( is_array( $data ) && ! empty( $data ) ) {
return $data;
}
// Last resort: bypass object cache entirely.
global $wpdb;
$raw = $wpdb->get_var($wpdb->prepare(
"SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id=%d AND meta_key='_elementor_data' LIMIT 1",
$post_id
));
if (!empty($raw) && is_string($raw)) {
$decoded = json_decode($raw, true);
if (is_array($decoded)) return $decoded;
// Fallback: read from raw post meta (handles proxy / REST contexts).
$raw = get_post_meta( $post_id, '_elementor_data', true );
if ( ! empty( $raw ) && is_string( $raw ) ) {
$decoded = json_decode( $raw, true );
if ( is_array( $decoded ) ) {
return $decoded;
}
}
return [];
return array();
}
// -------------------------------------------------------------------------
// Save page data
// get_page_settings
// -------------------------------------------------------------------------
/**
* Saves element tree for an Elementor page.
*
* CLI/cron: raw $wpdb INSERT/UPDATE + object cache flush.
* HTTP: document->save() first; on failure falls back to direct meta write
* with wp_slash() + CSS cache invalidation (mirrors v1.9.0 fallback).
*/
public function save_page_data(int $post_id, array $data) {
// CLI/cron: skip document->save() entirely.
// - avoids wp_update_post mail crash (no sendmail in container)
// - avoids WP object cache stale-write problem in long-running scripts
if (PHP_SAPI === 'cli' || (defined('DOING_CRON') && DOING_CRON)) {
public function get_page_settings( int $post_id ) {
$document = $this->get_document( $post_id );
if ( is_wp_error( $document ) ) {
return $document;
}
return $document->get_settings();
}
// -------------------------------------------------------------------------
// save_page_data
// Upstream v1.9.0: try document->save() first; fall back to direct meta
// write with wp_slash() + CSS cache invalidation.
// CLI/cron uses raw $wpdb to avoid object-cache inconsistencies and the
// wp_update_post email fatal (known issue #3 in 05-KNOWN-ISSUES.md).
// -------------------------------------------------------------------------
public function save_page_data( int $post_id, array $data ) {
// CLI/cron fast path: raw DB write, bypasses object cache + document->save().
if ( PHP_SAPI === 'cli' || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
global $wpdb;
$json = wp_json_encode($data);
$json = wp_json_encode( $data );
$table = $wpdb->postmeta;
// _elementor_data
$exists = $wpdb->get_var($wpdb->prepare(
$exists = $wpdb->get_var( $wpdb->prepare(
"SELECT meta_id FROM {$table} WHERE post_id=%d AND meta_key='_elementor_data' LIMIT 1",
$post_id
));
if ($exists) {
$wpdb->query($wpdb->prepare(
) );
if ( $exists ) {
$wpdb->query( $wpdb->prepare(
"UPDATE {$table} SET meta_value=%s WHERE meta_id=%d",
$json, (int)$exists
));
$json, (int) $exists
) );
} else {
$wpdb->query($wpdb->prepare(
$wpdb->query( $wpdb->prepare(
"INSERT INTO {$table} (post_id, meta_key, meta_value) VALUES (%d, '_elementor_data', %s)",
$post_id, $json
));
) );
}
// _elementor_edit_mode
$modeExists = $wpdb->get_var($wpdb->prepare(
// Ensure Elementor meta flags are set.
$mode_exists = $wpdb->get_var( $wpdb->prepare(
"SELECT meta_id FROM {$table} WHERE post_id=%d AND meta_key='_elementor_edit_mode' LIMIT 1",
$post_id
));
if ($modeExists) {
$wpdb->query($wpdb->prepare(
) );
if ( $mode_exists ) {
$wpdb->query( $wpdb->prepare(
"UPDATE {$table} SET meta_value='builder' WHERE meta_id=%d",
(int)$modeExists
));
(int) $mode_exists
) );
} else {
$wpdb->query($wpdb->prepare(
$wpdb->query( $wpdb->prepare(
"INSERT INTO {$table} (post_id, meta_key, meta_value) VALUES (%d, '_elementor_edit_mode', 'builder')",
$post_id
));
) );
}
// _elementor_version
if (defined('ELEMENTOR_VERSION')) {
$verExists = $wpdb->get_var($wpdb->prepare(
if ( defined( 'ELEMENTOR_VERSION' ) ) {
$ver_exists = $wpdb->get_var( $wpdb->prepare(
"SELECT meta_id FROM {$table} WHERE post_id=%d AND meta_key='_elementor_version' LIMIT 1",
$post_id
));
if ($verExists) {
$wpdb->query($wpdb->prepare(
) );
if ( $ver_exists ) {
$wpdb->query( $wpdb->prepare(
"UPDATE {$table} SET meta_value=%s WHERE meta_id=%d",
ELEMENTOR_VERSION, (int)$verExists
));
ELEMENTOR_VERSION, (int) $ver_exists
) );
} else {
$wpdb->query($wpdb->prepare(
$wpdb->query( $wpdb->prepare(
"INSERT INTO {$table} (post_id, meta_key, meta_value) VALUES (%d, '_elementor_version', %s)",
$post_id, ELEMENTOR_VERSION
));
) );
}
}
// Invalidate _elementor_css meta so Elementor regenerates it.
$wpdb->query($wpdb->prepare(
// Invalidate Elementor CSS cache so it regenerates on next page view.
$wpdb->query( $wpdb->prepare(
"DELETE FROM {$table} WHERE post_id=%d AND meta_key='_elementor_css'",
$post_id
));
) );
// Remove physical CSS file so front-end regenerates on next load.
$upload_dir = wp_get_upload_dir();
$css_path = $upload_dir['basedir'] . '/elementor/css/post-' . $post_id . '.css';
if (file_exists($css_path)) {
@unlink($css_path);
if ( file_exists( $css_path ) ) {
wp_delete_file( $css_path );
}
// Flush WP object cache for this post so next get_post_meta reads fresh.
wp_cache_delete($post_id, 'post_meta');
// Invalidate WP object cache so next get_post_meta in same process reads fresh.
wp_cache_delete( $post_id, 'post_meta' );
return true;
}
// HTTP: attempt native Elementor save (handles CSS regen, cache busting).
$document = $this->get_document($post_id);
if (is_wp_error($document)) return $document;
// Web context: try Elementor native save (handles CSS regen, cache busting).
$document = $this->get_document( $post_id );
if ( is_wp_error( $document ) ) {
return $document;
}
$result = $document->save(['elements' => $data]);
$result = $document->save( array( 'elements' => $data ) );
if (false === $result) {
// Fallback: direct meta write for non-browser contexts (REST proxy etc.).
$json = wp_json_encode($data);
if (false === $json) {
return new \WP_Error('json_encode_failed', 'Failed to encode element data as JSON.');
if ( false === $result ) {
// Fallback: direct meta write for non-browser contexts (REST proxy).
// wp_slash() is required — WordPress double-unslashes on save.
$json = wp_json_encode( $data );
if ( false === $json ) {
return new \WP_Error( 'json_encode_failed', 'Failed to encode element data as JSON.' );
}
// wp_slash() is required — update_post_meta strips one level of slashes.
update_post_meta($post_id, '_elementor_data', wp_slash($json));
update_post_meta($post_id, '_elementor_edit_mode', 'builder');
update_post_meta( $post_id, '_elementor_data', wp_slash( $json ) );
update_post_meta( $post_id, '_elementor_edit_mode', 'builder' );
if (defined('ELEMENTOR_VERSION')) {
update_post_meta($post_id, '_elementor_version', ELEMENTOR_VERSION);
if ( defined( 'ELEMENTOR_VERSION' ) ) {
update_post_meta( $post_id, '_elementor_version', ELEMENTOR_VERSION );
}
// Invalidate Elementor CSS cache so it regenerates on next page view.
delete_post_meta($post_id, '_elementor_css');
// Invalidate CSS cache.
delete_post_meta( $post_id, '_elementor_css' );
$upload_dir = wp_get_upload_dir();
$css_path = $upload_dir['basedir'] . '/elementor/css/post-' . $post_id . '.css';
if (file_exists($css_path)) {
wp_delete_file($css_path);
if ( file_exists( $css_path ) ) {
wp_delete_file( $css_path );
}
}
@@ -212,110 +210,80 @@ class ElementorData {
}
// -------------------------------------------------------------------------
// Page settings
// save_page_settings
// Upstream v1.9.0: try document->save(); fall back to direct meta merge.
// -------------------------------------------------------------------------
public function get_page_settings(int $post_id) {
$document = $this->get_document($post_id);
if (is_wp_error($document)) return $document;
return $document->get_settings();
}
public function save_page_settings( int $post_id, array $settings ) {
$document = $this->get_document( $post_id );
if ( is_wp_error( $document ) ) {
return $document;
}
/**
* Saves page-level settings.
*
* Tries native document->save() first; falls back to direct meta merge
* for non-browser contexts (mirrors v1.9.0 save_page_settings fallback).
*/
public function save_page_settings(int $post_id, array $settings) {
$document = $this->get_document($post_id);
if (is_wp_error($document)) return $document;
$result = $document->save( array( 'settings' => $settings ) );
$result = $document->save(['settings' => $settings]);
if (false === $result) {
$existing = get_post_meta($post_id, '_elementor_page_settings', true);
if (!is_array($existing)) $existing = [];
$merged = array_merge($existing, $settings);
update_post_meta($post_id, '_elementor_page_settings', $merged);
delete_post_meta($post_id, '_elementor_css');
if ( false === $result ) {
$existing = get_post_meta( $post_id, '_elementor_page_settings', true );
if ( ! is_array( $existing ) ) {
$existing = array();
}
update_post_meta( $post_id, '_elementor_page_settings', array_merge( $existing, $settings ) );
delete_post_meta( $post_id, '_elementor_css' );
}
return true;
}
// -------------------------------------------------------------------------
// Widget registry
// find_element (upstream: find_element_by_id)
// -------------------------------------------------------------------------
public function get_registered_widgets(): array {
if (!class_exists('\Elementor\Plugin')) return [];
$widgets = \Elementor\Plugin::$instance->widgets_manager->get_widget_types();
$result = [];
foreach ($widgets as $widget) {
$result[] = ['name' => $widget->get_name(), 'title' => $widget->get_title()];
}
return $result;
}
// -------------------------------------------------------------------------
// ID generation
// -------------------------------------------------------------------------
/**
* Generates a 7-character random hex string matching Elementor's format.
* Uses random_bytes() for proper entropy (mirrors Elementor_MCP_Id_Generator).
*/
public function generate_id(): string {
return substr(bin2hex(random_bytes(4)), 0, 7);
}
// -------------------------------------------------------------------------
// Element tree helpers
// -------------------------------------------------------------------------
/**
* Recursively finds an element by ID within a tree.
* Mirrors find_element_by_id() from Elementor_MCP_Data v1.9.0.
*/
public function find_element(array $elements, string $element_id): ?array {
foreach ($elements as $element) {
if (($element['id'] ?? '') === $element_id) return $element;
if (!empty($element['elements'])) {
$found = $this->find_element($element['elements'], $element_id);
if ($found !== null) return $found;
public function find_element( array $elements, string $element_id ): ?array {
foreach ( $elements as $element ) {
if ( ( $element['id'] ?? '' ) === $element_id ) {
return $element;
}
if ( ! empty( $element['elements'] ) && is_array( $element['elements'] ) ) {
$found = $this->find_element( $element['elements'], $element_id );
if ( null !== $found ) {
return $found;
}
}
}
return null;
}
/**
* Inserts an element into the tree under parent_id at position.
* Empty parent_id = top-level insertion.
* Mirrors insert_element() from Elementor_MCP_Data v1.9.0 (by-reference).
*/
public function insert_element(array &$data, string $parent_id, array $element, int $position = -1): bool {
if (empty($parent_id)) {
if ($position < 0 || $position >= count($data)) {
// -------------------------------------------------------------------------
// insert_element (upstream: insert_element — by reference)
// Supports top-level insertion (empty parent_id) and nested.
// -------------------------------------------------------------------------
public function insert_element( array &$data, string $parent_id, array $element, int $position = -1 ): bool {
// Top-level insertion.
if ( empty( $parent_id ) ) {
if ( $position < 0 || $position >= count( $data ) ) {
$data[] = $element;
} else {
array_splice($data, $position, 0, [$element]);
array_splice( $data, $position, 0, array( $element ) );
}
return true;
}
foreach ($data as &$item) {
if (isset($item['id']) && $item['id'] === $parent_id) {
if (!isset($item['elements'])) $item['elements'] = [];
if ($position < 0 || $position >= count($item['elements'])) {
// Find parent and insert.
foreach ( $data as &$item ) {
if ( isset( $item['id'] ) && $item['id'] === $parent_id ) {
if ( ! isset( $item['elements'] ) ) {
$item['elements'] = array();
}
if ( $position < 0 || $position >= count( $item['elements'] ) ) {
$item['elements'][] = $element;
} else {
array_splice($item['elements'], $position, 0, [$element]);
array_splice( $item['elements'], $position, 0, array( $element ) );
}
return true;
}
if (!empty($item['elements']) && is_array($item['elements'])) {
if ($this->insert_element($item['elements'], $parent_id, $element, $position)) {
if ( ! empty( $item['elements'] ) && is_array( $item['elements'] ) ) {
if ( $this->insert_element( $item['elements'], $parent_id, $element, $position ) ) {
return true;
}
}
@@ -324,18 +292,18 @@ class ElementorData {
return false;
}
/**
* Removes an element from the tree by ID.
* Mirrors remove_element() from Elementor_MCP_Data v1.9.0 (by-reference).
*/
public function remove_element(array &$data, string $element_id): bool {
foreach ($data as $index => &$item) {
if (isset($item['id']) && $item['id'] === $element_id) {
array_splice($data, $index, 1);
// -------------------------------------------------------------------------
// remove_element (upstream: remove_element — by reference)
// -------------------------------------------------------------------------
public function remove_element( array &$data, string $element_id ): bool {
foreach ( $data as $index => &$item ) {
if ( isset( $item['id'] ) && $item['id'] === $element_id ) {
array_splice( $data, $index, 1 );
return true;
}
if (!empty($item['elements']) && is_array($item['elements'])) {
if ($this->remove_element($item['elements'], $element_id)) {
if ( ! empty( $item['elements'] ) && is_array( $item['elements'] ) ) {
if ( $this->remove_element( $item['elements'], $element_id ) ) {
return true;
}
}
@@ -343,26 +311,32 @@ class ElementorData {
return false;
}
/**
* Updates settings for a specific element in the tree (by-reference).
*
* Applies container key alias normalisation (justify_content → flex_justify_content
* etc.) before merging — mirrors update_element_settings() v1.9.0 which calls
* normalize_container_settings() when elType=container.
*/
public function update_element_settings(array &$data, string $element_id, array $settings, bool $merge = true): bool {
foreach ($data as &$item) {
if (($item['id'] ?? '') === $element_id) {
// Normalise flex shorthand keys for containers.
if (($item['elType'] ?? '') === 'container') {
$settings = self::normalize_container_settings($settings);
// -------------------------------------------------------------------------
// update_element_settings (upstream: update_element_settings — by reference)
// Normalises container flex-key aliases before merging (issue #32 fix from
// upstream v1.5.1): justify_content→flex_justify_content, etc.
// -------------------------------------------------------------------------
public function update_element_settings( array &$data, string $element_id, array $settings, bool $merge = true ): bool {
foreach ( $data as &$item ) {
if ( isset( $item['id'] ) && $item['id'] === $element_id ) {
if ( ! isset( $item['settings'] ) ) {
$item['settings'] = array();
}
$current = $item['settings'] ?? [];
$item['settings'] = $merge ? array_merge($current, $settings) : $settings;
// Normalise container flex shorthand keys (upstream v1.5.1 fix).
if ( 'container' === ( $item['elType'] ?? '' ) ) {
$settings = $this->normalize_container_settings( $settings );
}
$item['settings'] = $merge
? array_merge( $item['settings'], $settings )
: $settings;
return true;
}
if (!empty($item['elements']) && is_array($item['elements'])) {
if ($this->update_element_settings($item['elements'], $element_id, $settings, $merge)) {
if ( ! empty( $item['elements'] ) && is_array( $item['elements'] ) ) {
if ( $this->update_element_settings( $item['elements'], $element_id, $settings, $merge ) ) {
return true;
}
}
@@ -370,74 +344,84 @@ class ElementorData {
return false;
}
/**
* Recursively reassigns fresh IDs to all elements in a tree.
* Mirrors reassign_ids() from Elementor_MCP_Data v1.9.0.
*/
public function reassign_ids(array $elements): array {
foreach ($elements as &$element) {
// -------------------------------------------------------------------------
// normalize_container_settings
// Upstream v1.9.0 Element_Factory::normalize_container_settings().
// Rewrites unprefixed flex shorthand keys to the keys Elementor CSS
// generator actually reads (issue #32).
// -------------------------------------------------------------------------
public function normalize_container_settings( array $settings ): array {
$aliases = array(
'justify_content' => 'flex_justify_content',
'align_items' => 'flex_align_items',
'align_content' => 'flex_align_content',
);
foreach ( $aliases as $shorthand => $flex_key ) {
if ( ! array_key_exists( $shorthand, $settings ) ) {
continue;
}
if ( ! array_key_exists( $flex_key, $settings ) ) {
$settings[ $flex_key ] = $settings[ $shorthand ];
}
unset( $settings[ $shorthand ] );
}
return $settings;
}
// -------------------------------------------------------------------------
// reassign_ids (upstream: reassign_ids + reassign_element_ids)
// -------------------------------------------------------------------------
public function reassign_ids( array $elements ): array {
foreach ( $elements as &$element ) {
$element['id'] = $this->generate_id();
if (!empty($element['elements']) && is_array($element['elements'])) {
$element['elements'] = $this->reassign_ids($element['elements']);
if ( ! empty( $element['elements'] ) && is_array( $element['elements'] ) ) {
$element['elements'] = $this->reassign_ids( $element['elements'] );
}
}
return $elements;
}
/**
* Reassigns a fresh ID to a single element and all its children.
* Mirrors reassign_element_ids() from Elementor_MCP_Data v1.9.0.
*/
public function reassign_element_ids(array $element): array {
$element['id'] = $this->generate_id();
if (!empty($element['elements']) && is_array($element['elements'])) {
$element['elements'] = $this->reassign_ids($element['elements']);
}
return $element;
}
// -------------------------------------------------------------------------
// count_elements (upstream: count_elements)
// -------------------------------------------------------------------------
/**
* Recursively counts all elements in a tree.
*/
public function count_elements(array $elements): int {
$count = count($elements);
foreach ($elements as $element) {
if (!empty($element['elements'])) {
$count += $this->count_elements($element['elements']);
public function count_elements( array $elements ): int {
$count = count( $elements );
foreach ( $elements as $element ) {
if ( ! empty( $element['elements'] ) && is_array( $element['elements'] ) ) {
$count += $this->count_elements( $element['elements'] );
}
}
return $count;
}
// -------------------------------------------------------------------------
// Container key normalisation
// get_registered_widgets
// -------------------------------------------------------------------------
/**
* Rewrites unprefixed flex shorthand keys to the prefixed keys that
* Elementor's container CSS generator actually reads.
*
* Without this, justify_content / align_items / align_content are persisted
* under names Elementor ignores and the container renders with default
* alignment on the front-end.
*
* Mirrors CONTAINER_KEY_ALIASES + normalize_container_settings() from
* Elementor_MCP_Element_Factory v1.9.0.
*/
private const CONTAINER_KEY_ALIASES = [
'justify_content' => 'flex_justify_content',
'align_items' => 'flex_align_items',
'align_content' => 'flex_align_content',
];
public static function normalize_container_settings(array $settings): array {
foreach (self::CONTAINER_KEY_ALIASES as $shorthand => $flex_key) {
if (!array_key_exists($shorthand, $settings)) continue;
if (!array_key_exists($flex_key, $settings)) {
$settings[$flex_key] = $settings[$shorthand];
}
unset($settings[$shorthand]);
public function get_registered_widgets(): array {
if ( ! class_exists( '\Elementor\Plugin' ) ) {
return array();
}
return $settings;
$widgets = \Elementor\Plugin::$instance->widgets_manager->get_widget_types();
$result = array();
foreach ( $widgets as $widget ) {
$result[] = array(
'name' => $widget->get_name(),
'title' => $widget->get_title(),
);
}
return $result;
}
// -------------------------------------------------------------------------
// generate_id (upstream: Elementor_MCP_Id_Generator::generate)
// 7-char hex matching Elementor's internal format.
// -------------------------------------------------------------------------
public function generate_id(): string {
return substr( bin2hex( random_bytes( 4 ) ), 0, 7 );
}
}