PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / trunk
Yoast SEO – Advanced SEO with real-time guidance and built-in AI vtrunk
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
← All changes | src/integrations/third-party/elementor.php +263 -158 18.0 → trunk View file →
@@ -1,17 +1,14 @@
1 1 <?php
2 2
3 3 namespace Yoast\WP\SEO\Integrations\Third_Party;
4 4
5 -use Elementor\Controls_Manager;
6 -use Elementor\Core\DocumentTypes\PageBase;
5 +use Elementor\Plugin;
7 6 use WP_Post;
8 -use WP_Screen;
9 7 use WPSEO_Admin_Asset_Manager;
10 -use WPSEO_Admin_Asset_Yoast_Components_L10n;
11 8 use WPSEO_Admin_Recommended_Replace_Vars;
12 -use WPSEO_Language_Utils;
13 9 use WPSEO_Meta;
10 +use WPSEO_Metabox_Analysis_Inclusive_Language;
14 11 use WPSEO_Metabox_Analysis_Readability;
15 12 use WPSEO_Metabox_Analysis_SEO;
16 13 use WPSEO_Metabox_Formatter;
17 14 use WPSEO_Post_Metabox_Formatter;
@@ -16,16 +13,17 @@
16 13 use WPSEO_Metabox_Formatter;
17 14 use WPSEO_Post_Metabox_Formatter;
18 15 use WPSEO_Replace_Vars;
19 16 use WPSEO_Utils;
20 -use Yoast\WP\SEO\Actions\Alert_Dismissal_Action;
21 -use Yoast\WP\SEO\Conditionals\Admin\Estimated_Reading_Time_Conditional;
22 17 use Yoast\WP\SEO\Conditionals\Third_Party\Elementor_Edit_Conditional;
23 -use Yoast\WP\SEO\Config\Wincher_Links;
18 +use Yoast\WP\SEO\Editors\Application\Site\Website_Information_Repository;
19 +use Yoast\WP\SEO\Elementor\Infrastructure\Request_Post;
24 20 use Yoast\WP\SEO\Helpers\Capability_Helper;
21 +use Yoast\WP\SEO\Helpers\Current_Page_Helper;
25 22 use Yoast\WP\SEO\Helpers\Options_Helper;
26 23 use Yoast\WP\SEO\Integrations\Integration_Interface;
27 24 use Yoast\WP\SEO\Presenters\Admin\Meta_Fields_Presenter;
25 +use Yoast\WP\SEO\Promotions\Application\Promotion_Manager;
28 26
29 27 /**
30 28 * Integrates the Yoast SEO metabox in the Elementor editor.
31 29 */
@@ -31,13 +29,8 @@
31 29 */
32 30 class Elementor implements Integration_Interface {
33 31
34 32 /**
35 - * The identifier for the elementor tab.
36 - */
37 - const YOAST_TAB = 'yoast-tab';
38 -
39 - /**
40 33 * Represents the post.
41 34 *
42 35 * @var WP_Post|null
43 36 */
@@ -64,8 +57,22 @@
64 57 */
65 58 protected $capability;
66 59
67 60 /**
61 + * Holds the Request_Post.
62 + *
63 + * @var Request_Post
64 + */
65 + private $request_post;
66 +
67 + /**
68 + * Holds the current page helper.
69 + *
70 + * @var Current_Page_Helper
71 + */
72 + private $current_page_helper;
73 +
74 + /**
68 75 * Holds whether the socials are enabled.
69 76 *
70 77 * @var bool
71 78 */
@@ -92,15 +99,22 @@
92 99 */
93 100 protected $readability_analysis;
94 101
95 102 /**
96 - * Represents the estimated_reading_time_conditional.
103 + * Helper to determine whether or not the inclusive language analysis is enabled.
97 104 *
98 - * @var Estimated_Reading_Time_Conditional
105 + * @var WPSEO_Metabox_Analysis_Inclusive_Language
99 106 */
100 - protected $estimated_reading_time_conditional;
107 + protected $inclusive_language_analysis;
101 108
102 109 /**
110 + * Holds the promotion manager.
111 + *
112 + * @var Promotion_Manager
113 + */
114 + protected $promotion_manager;
115 +
116 + /**
103 117 * Returns the conditionals based in which this loadable should be active.
104 118 *
105 119 * @return array
106 120 */
@@ -110,29 +124,32 @@
110 124
111 125 /**
112 126 * Constructor.
113 127 *
114 - * @param WPSEO_Admin_Asset_Manager $asset_manager The asset manager.
115 - * @param Options_Helper $options The options helper.
116 - * @param Capability_Helper $capability The capability helper.
117 - * @param Estimated_Reading_Time_Conditional $estimated_reading_time_conditional The Estimated Reading Time
118 - * conditional.
128 + * @param WPSEO_Admin_Asset_Manager $asset_manager The asset manager.
129 + * @param Options_Helper $options The options helper.
130 + * @param Capability_Helper $capability The capability helper.
131 + * @param Request_Post $request_post The Request_Post.
132 + * @param Current_Page_Helper $current_page_helper The current page helper.
119 133 */
120 134 public function __construct(
121 135 WPSEO_Admin_Asset_Manager $asset_manager,
122 136 Options_Helper $options,
123 137 Capability_Helper $capability,
124 - Estimated_Reading_Time_Conditional $estimated_reading_time_conditional
138 + Request_Post $request_post,
139 + Current_Page_Helper $current_page_helper
125 140 ) {
126 - $this->asset_manager = $asset_manager;
127 - $this->options = $options;
128 - $this->capability = $capability;
141 + $this->asset_manager = $asset_manager;
142 + $this->options = $options;
143 + $this->capability = $capability;
144 + $this->request_post = $request_post;
145 + $this->current_page_helper = $current_page_helper;
129 146
130 - $this->seo_analysis = new WPSEO_Metabox_Analysis_SEO();
131 - $this->readability_analysis = new WPSEO_Metabox_Analysis_Readability();
132 - $this->social_is_enabled = $this->options->get( 'opengraph', false ) || $this->options->get( 'twitter', false );
133 - $this->is_advanced_metadata_enabled = $this->capability->current_user_can( 'wpseo_edit_advanced_metadata' ) || $this->options->get( 'disableadvanced_meta' ) === false;
134 - $this->estimated_reading_time_conditional = $estimated_reading_time_conditional;
147 + $this->seo_analysis = new WPSEO_Metabox_Analysis_SEO();
148 + $this->readability_analysis = new WPSEO_Metabox_Analysis_Readability();
149 + $this->inclusive_language_analysis = new WPSEO_Metabox_Analysis_Inclusive_Language();
150 + $this->social_is_enabled = $this->options->get( 'opengraph', false ) || $this->options->get( 'twitter', false );
151 + $this->is_advanced_metadata_enabled = $this->capability->current_user_can( 'wpseo_edit_advanced_metadata' ) || $this->options->get( 'disableadvanced_meta' ) === false;
135 152 }
136 153
137 154 /**
138 155 * Initializes the integration.
@@ -149,24 +166,20 @@
149 166 }
150 167
151 168 /**
152 169 * Registers our Elementor hooks.
170 + * This is done for pages with metabox on page load and not on ajax request.
171 + *
172 + * @return void
153 173 */
154 174 public function register_elementor_hooks() {
155 - if ( ! $this->display_metabox( $this->get_metabox_post()->post_type ) ) {
175 + if ( $this->get_metabox_post() === null || ! $this->display_metabox( $this->get_metabox_post()->post_type ) ) {
156 176 return;
157 177 }
158 178
159 179 \add_action( 'elementor/editor/before_enqueue_scripts', [ $this, 'init' ] );
160 -
161 - // We are too late for elementor/init. We should see if we can be on time, or else this workaround works (we do always get the "else" though).
162 - if ( ! \did_action( 'elementor/init' ) ) {
163 - \add_action( 'elementor/init', [ $this, 'add_yoast_panel_tab' ] );
164 - }
165 - else {
166 - $this->add_yoast_panel_tab();
167 - }
168 - \add_action( 'elementor/documents/register_controls', [ $this, 'register_document_controls' ] );
180 + \add_action( 'elementor/editor/footer', [ $this, 'start_output_buffering' ], 0 );
181 + \add_action( 'elementor/editor/footer', [ $this, 'inject_yoast_tab' ], 999 );
169 182 }
170 183
171 184 /**
172 185 * Initializes the integration.
@@ -179,35 +192,43 @@
179 192 $this->render_hidden_fields();
180 193 }
181 194
182 195 /**
183 - * Register a panel tab slug, in order to allow adding controls to this tab.
196 + * Start capturing buffer.
197 + *
198 + * @return void
184 199 */
185 - public function add_yoast_panel_tab() {
186 - Controls_Manager::add_tab( $this::YOAST_TAB, \__( 'Yoast SEO', 'wordpress-seo' ) );
200 + public function start_output_buffering() {
201 + \ob_start();
187 202 }
188 203
189 204 /**
190 - * Register additional document controls.
205 + * Injects the Yoast SEO tab into the Elements panel of the Elementor editor.
191 206 *
192 - * @param PageBase $document The PageBase document.
207 + * @return void
193 208 */
194 - public function register_document_controls( $document ) {
195 - // PageBase is the base class for documents like `post` `page` and etc.
196 - if ( ! $document instanceof PageBase || ! $document::get_property( 'has_elements' ) ) {
209 + public function inject_yoast_tab() {
210 + $output = \ob_get_clean();
211 +
212 + // If the buffer is empty or the call failed, bail out.
213 + if ( empty( $output ) ) {
197 214 return;
198 215 }
199 216
200 - // This is needed to get the tab to appear, but will be overwritten in the JavaScript.
201 - $document->start_controls_section(
202 - 'yoast_temporary_section',
203 - [
204 - 'label' => \__( 'Yoast SEO', 'wordpress-seo' ),
205 - 'tab' => self::YOAST_TAB,
206 - ]
207 - );
217 + $search = '/(<(div|button) class="elementor-component-tab elementor-panel-navigation-tab" data-tab="global">.*<\/(div|button)>)/m';
218 + $replace = '${1}<${2} class="elementor-component-tab elementor-panel-navigation-tab" data-tab="yoast-seo-tab">Yoast SEO</${2}>';
208 219
209 - $document->end_controls_section();
220 + $modified_output = \preg_replace( $search, $replace, $output );
221 +
222 + // Check if preg_replace failed. If so, fallback to original output.
223 + if ( $modified_output === null ) {
224 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Reason: Already escaped output.
225 + echo $output;
226 + return;
227 + }
228 +
229 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Reason: Already escaped output.
230 + echo $modified_output;
210 231 }
211 232
212 233 // Below is mostly copied from `class-metabox.php`. That constructor has side-effects we do not need.
213 234
@@ -213,14 +234,14 @@
213 234
214 235 /**
215 236 * Determines whether the metabox should be shown for the passed identifier.
216 237 *
217 - * By default the check is done for post types, but can also be used for taxonomies.
238 + * By default, the check is done for post types, but can also be used for taxonomies.
218 239 *
219 240 * @param string|null $identifier The identifier to check.
220 241 * @param string $type The type of object to check. Defaults to post_type.
221 242 *
222 - * @return bool Whether or not the metabox should be displayed.
243 + * @return bool Whether the metabox should be displayed.
223 244 */
224 245 public function display_metabox( $identifier = null, $type = 'post_type' ) {
225 246 return WPSEO_Utils::is_metabox_active( $identifier, $type );
226 247 }
@@ -236,14 +257,23 @@
236 257 */
237 258 public function save_postdata() {
238 259 global $post;
239 260
240 - $post_id = \filter_input( \INPUT_POST, 'post_id', \FILTER_SANITIZE_NUMBER_INT );
261 + if ( ! isset( $_POST['post_id'] ) || ! \is_string( $_POST['post_id'] ) ) {
262 + \wp_send_json_error( 'Bad Request', 400 );
263 + }
241 264
242 - if ( ! \current_user_can( 'manage_options' ) ) {
243 - \wp_send_json_error( 'Unauthorized', 401 );
265 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Reason: No sanitization needed because we cast to an integer.
266 + $post_id = (int) \wp_unslash( $_POST['post_id'] );
267 +
268 + if ( $post_id <= 0 ) {
269 + \wp_send_json_error( 'Bad Request', 400 );
244 270 }
245 271
272 + if ( ! \current_user_can( 'edit_post', $post_id ) ) {
273 + \wp_send_json_error( 'Forbidden', 403 );
274 + }
275 +
246 276 \check_ajax_referer( 'wpseo_elementor_save', '_wpseo_elementor_nonce' );
247 277
248 278 // Bail if this is a multisite installation and the site has been switched.
249 279 if ( \is_multisite() && \ms_is_switched() ) {
@@ -276,9 +306,9 @@
276 306 $meta_boxes,
277 307 WPSEO_Meta::get_meta_field_defs( 'general', $post->post_type ),
278 308 WPSEO_Meta::get_meta_field_defs( 'advanced', $post->post_type ),
279 309 $social_fields,
280 - WPSEO_Meta::get_meta_field_defs( 'schema', $post->post_type )
310 + WPSEO_Meta::get_meta_field_defs( 'schema', $post->post_type ),
281 311 );
282 312
283 313 foreach ( $meta_boxes as $key => $meta_box ) {
284 314 // If analysis is disabled remove that analysis score value from the DB.
@@ -318,24 +348,25 @@
318 348 WPSEO_Meta::set_value( $key, $data, $post_id );
319 349 }
320 350 }
321 351
322 - // Saving the WP post to save the slug.
323 - $slug = \filter_input( \INPUT_POST, WPSEO_Meta::$form_prefix . 'slug', \FILTER_SANITIZE_STRING );
324 - if ( $post->post_name !== $slug ) {
325 - $post_array = $post->to_array();
326 - $post_array['post_name'] = $slug;
352 + if ( isset( $_POST[ WPSEO_Meta::$form_prefix . 'slug' ] ) && \is_string( $_POST[ WPSEO_Meta::$form_prefix . 'slug' ] ) ) {
353 + $slug = \sanitize_title( \wp_unslash( $_POST[ WPSEO_Meta::$form_prefix . 'slug' ] ) );
354 + if ( $post->post_name !== $slug ) {
355 + $post_array = $post->to_array();
356 + $post_array['post_name'] = $slug;
327 357
328 - $save_successful = \wp_insert_post( $post_array );
329 - if ( \is_wp_error( $save_successful ) ) {
330 - \wp_send_json_error( 'Slug not saved', 400 );
331 - }
358 + $save_successful = \wp_insert_post( $post_array );
359 + if ( \is_wp_error( $save_successful ) ) {
360 + \wp_send_json_error( 'Slug not saved', 400 );
361 + }
332 362
333 - // Update the post object to ensure we have the actual slug.
334 - // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Updating the post is needed to get the current slug.
335 - $post = \get_post( $post_id );
336 - if ( ! \is_object( $post ) ) {
337 - \wp_send_json_error( 'Updated slug not found', 400 );
363 + // Update the post object to ensure we have the actual slug.
364 + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Updating the post is needed to get the current slug.
365 + $post = \get_post( $post_id );
366 + if ( ! \is_object( $post ) ) {
367 + \wp_send_json_error( 'Updated slug not found', 400 );
368 + }
338 369 }
339 370 }
340 371
341 372 \do_action( 'wpseo_saved_postdata' );
@@ -359,8 +390,12 @@
359 390 if ( $key === 'content_score' && ! $this->readability_analysis->is_enabled() ) {
360 391 return true;
361 392 }
362 393
394 + if ( $key === 'inclusive_language_score' && ! $this->inclusive_language_analysis->is_enabled() ) {
395 + return true;
396 + }
397 +
363 398 return false;
364 399 }
365 400
366 401 /**
@@ -370,9 +405,14 @@
370 405 */
371 406 public function enqueue() {
372 407 $post_id = \get_queried_object_id();
373 408 if ( empty( $post_id ) ) {
374 - $post_id = \sanitize_text_field( \filter_input( \INPUT_GET, 'post' ) );
409 + $post_id = 0;
410 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information.
411 + if ( isset( $_GET['post'] ) && \is_string( $_GET['post'] ) ) {
412 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Recommended -- Reason: No sanitization needed because we cast to an integer,We are not processing form information.
413 + $post_id = (int) \wp_unslash( $_GET['post'] );
414 + }
375 415 }
376 416
377 417 if ( $post_id !== 0 ) {
378 418 // Enqueue files needed for upload functionality.
@@ -380,20 +420,25 @@
380 420 }
381 421
382 422 $this->asset_manager->enqueue_style( 'admin-global' );
383 423 $this->asset_manager->enqueue_style( 'metabox-css' );
384 - $this->asset_manager->enqueue_style( 'scoring' );
385 - $this->asset_manager->enqueue_style( 'select2' );
424 + if ( $this->readability_analysis->is_enabled() ) {
425 + $this->asset_manager->enqueue_style( 'scoring' );
426 + }
386 427 $this->asset_manager->enqueue_style( 'monorepo' );
387 428 $this->asset_manager->enqueue_style( 'admin-css' );
429 + $this->asset_manager->enqueue_style( 'ai-generator' );
388 430 $this->asset_manager->enqueue_style( 'elementor' );
389 431
390 432 $this->asset_manager->enqueue_script( 'admin-global' );
391 433 $this->asset_manager->enqueue_script( 'elementor' );
392 434
393 - $yoast_components_l10n = new WPSEO_Admin_Asset_Yoast_Components_L10n();
394 - $yoast_components_l10n->localize_script( 'elementor' );
435 + $is_v4_atomic = $this->is_elementor_v4_atomic_active();
395 436
437 + if ( $is_v4_atomic ) {
438 + $this->asset_manager->enqueue_script( 'elementor-v4' );
439 + }
440 +
396 441 $this->asset_manager->localize_script( 'elementor', 'wpseoAdminGlobalL10n', \YoastSEO()->helpers->wincher->get_admin_global_links() );
397 442 $this->asset_manager->localize_script( 'elementor', 'wpseoAdminL10n', WPSEO_Utils::get_admin_l10n() );
398 443 $this->asset_manager->localize_script( 'elementor', 'wpseoFeaturesL10n', WPSEO_Utils::retrieve_enabled_features() );
399 444
@@ -398,9 +443,8 @@
398 443 $this->asset_manager->localize_script( 'elementor', 'wpseoFeaturesL10n', WPSEO_Utils::retrieve_enabled_features() );
399 444
400 445 $plugins_script_data = [
401 446 'replaceVars' => [
402 - 'no_parent_text' => \__( '(no parent)', 'wordpress-seo' ),
403 447 'replace_vars' => $this->get_replace_vars(),
404 448 'recommended_replace_vars' => $this->get_recommended_replace_vars(),
405 449 'hidden_replace_vars' => $this->get_hidden_replace_vars(),
406 450 'scope' => $this->determine_scope(),
@@ -406,10 +450,10 @@
406 450 'scope' => $this->determine_scope(),
407 451 'has_taxonomies' => $this->current_post_type_has_taxonomies(),
408 452 ],
409 453 'shortcodes' => [
454 + 'wpseo_shortcode_tags' => $this->get_valid_shortcode_tags(),
410 455 'wpseo_filter_shortcodes_nonce' => \wp_create_nonce( 'wpseo-filter-shortcodes' ),
411 - 'wpseo_shortcode_tags' => $this->get_valid_shortcode_tags(),
412 456 ],
413 457 ];
414 458
415 459 $worker_script_data = [
@@ -420,40 +464,93 @@
420 464 // We need to make the feature flags separately available inside of the analysis web worker.
421 465 'enabled_features' => WPSEO_Utils::retrieve_enabled_features(),
422 466 ];
423 467
424 - $alert_dismissal_action = \YoastSEO()->classes->get( Alert_Dismissal_Action::class );
425 - $dismissed_alerts = $alert_dismissal_action->all_dismissed();
468 + $permalink = $this->get_permalink();
469 + $page_on_front = (int) \get_option( 'page_on_front' );
470 + $homepage_is_page = \get_option( 'show_on_front' ) === 'page';
471 + $is_front_page = $homepage_is_page && $page_on_front === $post_id;
426 472
427 473 $script_data = [
428 - 'media' => [ 'choose_image' => \__( 'Use Image', 'wordpress-seo' ) ],
429 - 'metabox' => $this->get_metabox_script_data(),
430 - 'userLanguageCode' => WPSEO_Language_Utils::get_language( \get_user_locale() ),
431 - 'isPost' => true,
432 - 'isBlockEditor' => WP_Screen::get()->is_block_editor(),
433 - 'isElementorEditor' => true,
434 - 'postStatus' => get_post_status( $post_id ),
435 - 'analysis' => [
436 - 'plugins' => $plugins_script_data,
437 - 'worker' => $worker_script_data,
438 - 'estimatedReadingTimeEnabled' => $this->estimated_reading_time_conditional->is_met(),
474 + 'metabox' => $this->get_metabox_script_data( $permalink ),
475 + 'isPost' => true,
476 + 'isBlockEditor' => $this->current_page_helper->is_block_editor(),
477 + 'isElementorEditor' => true,
478 + 'isAlwaysIntroductionV2' => $this->is_elementor_version_compatible_with_introduction_v2(),
479 + 'isElementorV4Atomic' => $is_v4_atomic,
480 + 'postStatus' => \get_post_status( $post_id ),
481 + 'postType' => \get_post_type( $post_id ),
482 + 'analysis' => [
483 + 'plugins' => $plugins_script_data,
484 + 'worker' => $worker_script_data,
439 485 ],
440 - 'dismissedAlerts' => $dismissed_alerts,
486 + 'usedKeywordsNonce' => \wp_create_nonce( 'wpseo-keyword-usage-and-post-types' ),
487 + 'isFrontPage' => $is_front_page,
441 488 ];
442 489
443 - if ( \post_type_supports( $this->get_metabox_post()->post_type, 'thumbnail' ) ) {
444 - $this->asset_manager->enqueue_style( 'featured-image' );
490 + /**
491 + * The website information repository.
492 + *
493 + * @var Website_Information_Repository $repo
494 + */
495 + $repo = \YoastSEO()->classes->get( Website_Information_Repository::class );
496 + $site_information = $repo->get_post_site_information();
497 + $site_information->set_permalink( $permalink );
498 + $script_data = \array_merge_recursive( $site_information->get_legacy_site_information(), $script_data );
445 499
446 - $script_data['featuredImage'] = [
447 - 'featured_image_notice' => \__( 'SEO issue: The featured image should be at least 200 by 200 pixels to be picked up by Facebook and other social media sites.', 'wordpress-seo' ),
448 - ];
500 + $this->asset_manager->localize_script( 'elementor', 'wpseoScriptData', $script_data );
501 + }
502 +
503 + /**
504 + * Checks whether the current Elementor version is compatible with our introduction v2.
505 + *
506 + * In version 3.30.0, Elementor removed the experimental flag for the editor v2.
507 + * Resulting in the editor v2 being the default.
508 + *
509 + * @return bool Whether the Elementor version is compatible with introduction v2.
510 + */
511 + private function is_elementor_version_compatible_with_introduction_v2(): bool {
512 + if ( ! \defined( 'ELEMENTOR_VERSION' ) ) {
513 + return false;
449 514 }
450 515
451 - $this->asset_manager->localize_script( 'elementor', 'wpseoScriptData', $script_data );
452 - $this->asset_manager->enqueue_user_language_script();
516 + // Take the semver version from their version string.
517 + $matches = [];
518 + $version = ( \preg_match( '/^([0-9]+.[0-9]+.[0-9]+)/', \ELEMENTOR_VERSION, $matches ) > 0 ) ? $matches[1] : \ELEMENTOR_VERSION;
519 +
520 + // Check if the version is 3.30.0 or higher. This is where the editor v2 was taken out of the experimental into the default state.
521 + return \version_compare( $version, '3.30.0', '>=' );
453 522 }
454 523
455 524 /**
525 + * Checks whether Elementor V4 (the atomic editor) is currently active on this site.
526 + *
527 + * Mirrors Elementor's own atomic check (`Atomic_Widgets\OptIn\Opt_In::EXPERIMENT_NAME`):
528 + * the `e_opt_in_v4` experiment is the authoritative signal for the atomic editor and is
529 + * only registered on Elementor versions that ship it, so `is_feature_active()` already
530 + * returns false on older versions or partial installs. No separate version gate is used:
531 + * the experiment is an opt-in to V4 that can be enabled before the major version bump, so
532 + * a version comparison would wrongly suppress sites that opt in early. Defensive at each
533 + * step so missing Elementor internals never throw.
534 + *
535 + * @return bool Whether the V4 atomic editor path should be used.
536 + */
537 + private function is_elementor_v4_atomic_active(): bool {
538 + if ( ! \class_exists( '\Elementor\Plugin' ) ) {
539 + return false;
540 + }
541 + $elementor = ( Plugin::$instance ?? null );
542 + if ( $elementor === null || ! isset( $elementor->experiments ) ) {
543 + return false;
544 + }
545 + if ( ! \method_exists( $elementor->experiments, 'is_feature_active' ) ) {
546 + return false;
547 + }
548 +
549 + return (bool) $elementor->experiments->is_feature_active( 'e_opt_in_v4' );
550 + }
551 +
552 + /**
456 553 * Renders the metabox hidden fields.
457 554 *
458 555 * @return void
459 556 */
@@ -461,9 +558,9 @@
461 558 // Wrap in a form with an action and post_id for the submit.
462 559 \printf(
463 560 '<form id="yoast-form" method="post" action="%1$s"><input type="hidden" name="action" value="wpseo_elementor_save" /><input type="hidden" id="post_ID" name="post_id" value="%2$s" />',
464 561 \esc_url( \admin_url( 'admin-ajax.php' ) ),
465 - \esc_attr( $this->get_metabox_post()->ID )
562 + \esc_attr( $this->get_metabox_post()->ID ),
466 563 );
467 564
468 565 \wp_nonce_field( 'wpseo_elementor_save', '_wpseo_elementor_nonce' );
469 566 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Reason: Meta_Fields_Presenter->present is considered safe.
@@ -484,9 +581,14 @@
484 581
485 582 \printf(
486 583 '<input type="hidden" id="%1$s" name="%1$s" value="%2$s" />',
487 584 \esc_attr( WPSEO_Meta::$form_prefix . 'slug' ),
488 - \esc_attr( $this->get_post_slug() )
585 + /**
586 + * It is important that this slug value is the same as in the database.
587 + * If the DB value is empty we can auto-generate a slug.
588 + * But if not empty, we should not touch it anymore.
589 + */
590 + \esc_attr( $this->get_metabox_post()->post_name ),
489 591 );
490 592
491 593 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output should be escaped in the filter.
492 594 echo \apply_filters( 'wpseo_elementor_hidden_fields', '' );
@@ -494,68 +596,53 @@
494 596 echo '</form>';
495 597 }
496 598
497 599 /**
498 - * Returns the slug for the post being edited.
600 + * Returns post in metabox context.
499 601 *
500 - * @return string
602 + * @return WP_Post|null
501 603 */
502 - protected function get_post_slug() {
503 - $post = $this->get_metabox_post();
504 -
505 - // In case get_metabox_post returns null for whatever reason.
506 - if ( ! $post instanceof WP_Post ) {
507 - return '';
604 + protected function get_metabox_post() {
605 + if ( $this->post !== null ) {
606 + return $this->post;
508 607 }
509 608
510 - // Drafts might not have a post_name unless the slug has been manually changed.
511 - // In this case we get it using get_sample_permalink.
512 - if ( ! $post->post_name ) {
513 - $sample = \get_sample_permalink( $post );
609 + $this->post = $this->request_post->get_post();
514 610
515 - // Since get_sample_permalink runs through filters, ensure that it has the expected return value.
516 - if ( is_array( $sample ) && count( $sample ) === 2 && is_string( $sample[1] ) ) {
517 - return $sample[1];
518 - }
519 - }
520 -
521 - return $post->post_name;
611 + return $this->post;
522 612 }
523 613
524 614 /**
525 - * Returns post in metabox context.
615 + * Passes variables to js for use with the post-scraper.
526 616 *
527 - * @return WP_Post|null
617 + * @param string $permalink The permalink.
618 + *
619 + * @return array
528 620 */
529 - protected function get_metabox_post() {
530 - if ( $this->post !== null ) {
531 - return $this->post;
532 - }
621 + protected function get_metabox_script_data( $permalink ) {
622 + $post_formatter = new WPSEO_Metabox_Formatter(
623 + new WPSEO_Post_Metabox_Formatter( $this->get_metabox_post(), [], $permalink ),
624 + );
533 625
534 - $post = \filter_input( \INPUT_GET, 'post' );
535 - if ( ! empty( $post ) ) {
536 - $post_id = (int) WPSEO_Utils::validate_int( $post );
626 + $values = $post_formatter->get_values();
537 627
538 - $this->post = \get_post( $post_id );
539 -
540 - return $this->post;
628 + /** This filter is documented in admin/filters/class-cornerstone-filter.php. */
629 + $post_types = \apply_filters( 'wpseo_cornerstone_post_types', \YoastSEO()->helpers->post_type->get_accessible_post_types() );
630 + if ( $values['cornerstoneActive'] && ! \in_array( $this->get_metabox_post()->post_type, $post_types, true ) ) {
631 + $values['cornerstoneActive'] = false;
541 632 }
542 633
543 - if ( isset( $GLOBALS['post'] ) ) {
544 - $this->post = $GLOBALS['post'];
634 + $values['elementorMarkerStatus'] = $this->is_highlighting_available() ? 'enabled' : 'hidden';
545 635
546 - return $this->post;
547 - }
548 -
549 - return null;
636 + return $values;
550 637 }
551 638
552 639 /**
553 - * Passes variables to js for use with the post-scraper.
640 + * Gets the permalink.
554 641 *
555 - * @return array
642 + * @return string
556 643 */
557 - protected function get_metabox_script_data() {
644 + protected function get_permalink(): string {
558 645 $permalink = '';
559 646
560 647 if ( \is_object( $this->get_metabox_post() ) ) {
561 648 $permalink = \get_sample_permalink( $this->get_metabox_post()->ID );
@@ -561,21 +648,23 @@
561 648 $permalink = \get_sample_permalink( $this->get_metabox_post()->ID );
562 649 $permalink = $permalink[0];
563 650 }
564 651
565 - $post_formatter = new WPSEO_Metabox_Formatter(
566 - new WPSEO_Post_Metabox_Formatter( $this->get_metabox_post(), [], $permalink )
567 - );
652 + return $permalink;
653 + }
568 654
569 - $values = $post_formatter->get_values();
655 + /**
656 + * Checks whether the highlighting functionality is available for Elementor:
657 + * - in Free it's always available (as an upsell).
658 + * - in Premium it's available as long as the version is 21.8-RC0 or above.
659 + *
660 + * @return bool Whether the highlighting functionality is available.
661 + */
662 + private function is_highlighting_available() {
663 + $is_premium = \YoastSEO()->helpers->product->is_premium();
664 + $premium_version = \YoastSEO()->helpers->product->get_premium_version();
570 665
571 - /** This filter is documented in admin/filters/class-cornerstone-filter.php. */
572 - $post_types = \apply_filters( 'wpseo_cornerstone_post_types', \YoastSEO()->helpers->post_type->get_accessible_post_types() );
573 - if ( $values['cornerstoneActive'] && ! \in_array( $this->get_metabox_post()->post_type, $post_types, true ) ) {
574 - $values['cornerstoneActive'] = false;
575 - }
576 -
577 - return $values;
666 + return ! $is_premium || \version_compare( $premium_version, '21.8-RC0', '>=' );
578 667 }
579 668
580 669 /**
581 670 * Prepares the replace vars for localization.
@@ -708,11 +797,27 @@
708 797 }
709 798
710 799 $custom_fields = \get_post_custom( $post->ID );
711 800
801 + // Simply concatenate all fields containing replace vars so we can handle them all with a single regex find.
802 + $replace_vars_fields = \implode(
803 + ' ',
804 + [
805 + \YoastSEO()->meta->for_post( $post->ID )->presentation->title,
806 + \YoastSEO()->meta->for_post( $post->ID )->presentation->meta_description,
807 + ],
808 + );
809 +
810 + \preg_match_all( '/%%cf_([A-Za-z0-9_]+)%%/', $replace_vars_fields, $matches );
811 + $fields_to_include = $matches[1];
712 812 foreach ( $custom_fields as $custom_field_name => $custom_field ) {
713 813 // Skip private custom fields.
714 814 if ( \substr( $custom_field_name, 0, 1 ) === '_' ) {
815 + continue;
816 + }
817 +
818 + // Skip custom fields that are not used, new ones will be fetched dynamically.
819 + if ( ! \in_array( $custom_field_name, $fields_to_include, true ) ) {
715 820 continue;
716 821 }
717 822
718 823 // Skip custom field values that are serialized.