PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.5
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.5
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 +264 -154 18.228.5 View file →
@@ -1,16 +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 8 use WPSEO_Admin_Recommended_Replace_Vars;
11 -use WPSEO_Language_Utils;
12 9 use WPSEO_Meta;
10 +use WPSEO_Metabox_Analysis_Inclusive_Language;
13 11 use WPSEO_Metabox_Analysis_Readability;
14 12 use WPSEO_Metabox_Analysis_SEO;
15 13 use WPSEO_Metabox_Formatter;
16 14 use WPSEO_Post_Metabox_Formatter;
@@ -15,15 +13,17 @@
15 13 use WPSEO_Metabox_Formatter;
16 14 use WPSEO_Post_Metabox_Formatter;
17 15 use WPSEO_Replace_Vars;
18 16 use WPSEO_Utils;
19 -use Yoast\WP\SEO\Actions\Alert_Dismissal_Action;
20 -use Yoast\WP\SEO\Conditionals\Admin\Estimated_Reading_Time_Conditional;
21 17 use Yoast\WP\SEO\Conditionals\Third_Party\Elementor_Edit_Conditional;
18 +use Yoast\WP\SEO\Editors\Application\Site\Website_Information_Repository;
19 +use Yoast\WP\SEO\Elementor\Infrastructure\Request_Post;
22 20 use Yoast\WP\SEO\Helpers\Capability_Helper;
21 +use Yoast\WP\SEO\Helpers\Current_Page_Helper;
23 22 use Yoast\WP\SEO\Helpers\Options_Helper;
24 23 use Yoast\WP\SEO\Integrations\Integration_Interface;
25 24 use Yoast\WP\SEO\Presenters\Admin\Meta_Fields_Presenter;
25 +use Yoast\WP\SEO\Promotions\Application\Promotion_Manager;
26 26
27 27 /**
28 28 * Integrates the Yoast SEO metabox in the Elementor editor.
29 29 */
@@ -29,13 +29,8 @@
29 29 */
30 30 class Elementor implements Integration_Interface {
31 31
32 32 /**
33 - * The identifier for the elementor tab.
34 - */
35 - const YOAST_TAB = 'yoast-tab';
36 -
37 - /**
38 33 * Represents the post.
39 34 *
40 35 * @var WP_Post|null
41 36 */
@@ -62,8 +57,22 @@
62 57 */
63 58 protected $capability;
64 59
65 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 + /**
66 75 * Holds whether the socials are enabled.
67 76 *
68 77 * @var bool
69 78 */
@@ -90,15 +99,22 @@
90 99 */
91 100 protected $readability_analysis;
92 101
93 102 /**
94 - * Represents the estimated_reading_time_conditional.
103 + * Helper to determine whether or not the inclusive language analysis is enabled.
95 104 *
96 - * @var Estimated_Reading_Time_Conditional
105 + * @var WPSEO_Metabox_Analysis_Inclusive_Language
97 106 */
98 - protected $estimated_reading_time_conditional;
107 + protected $inclusive_language_analysis;
99 108
100 109 /**
110 + * Holds the promotion manager.
111 + *
112 + * @var Promotion_Manager
113 + */
114 + protected $promotion_manager;
115 +
116 + /**
101 117 * Returns the conditionals based in which this loadable should be active.
102 118 *
103 119 * @return array
104 120 */
@@ -108,29 +124,32 @@
108 124
109 125 /**
110 126 * Constructor.
111 127 *
112 - * @param WPSEO_Admin_Asset_Manager $asset_manager The asset manager.
113 - * @param Options_Helper $options The options helper.
114 - * @param Capability_Helper $capability The capability helper.
115 - * @param Estimated_Reading_Time_Conditional $estimated_reading_time_conditional The Estimated Reading Time
116 - * 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.
117 133 */
118 134 public function __construct(
119 135 WPSEO_Admin_Asset_Manager $asset_manager,
120 136 Options_Helper $options,
121 137 Capability_Helper $capability,
122 - Estimated_Reading_Time_Conditional $estimated_reading_time_conditional
138 + Request_Post $request_post,
139 + Current_Page_Helper $current_page_helper
123 140 ) {
124 - $this->asset_manager = $asset_manager;
125 - $this->options = $options;
126 - $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;
127 146
128 - $this->seo_analysis = new WPSEO_Metabox_Analysis_SEO();
129 - $this->readability_analysis = new WPSEO_Metabox_Analysis_Readability();
130 - $this->social_is_enabled = $this->options->get( 'opengraph', false ) || $this->options->get( 'twitter', false );
131 - $this->is_advanced_metadata_enabled = $this->capability->current_user_can( 'wpseo_edit_advanced_metadata' ) || $this->options->get( 'disableadvanced_meta' ) === false;
132 - $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;
133 152 }
134 153
135 154 /**
136 155 * Initializes the integration.
@@ -147,24 +166,20 @@
147 166 }
148 167
149 168 /**
150 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
151 173 */
152 174 public function register_elementor_hooks() {
153 - 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 ) ) {
154 176 return;
155 177 }
156 178
157 179 \add_action( 'elementor/editor/before_enqueue_scripts', [ $this, 'init' ] );
158 -
159 - // 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).
160 - if ( ! \did_action( 'elementor/init' ) ) {
161 - \add_action( 'elementor/init', [ $this, 'add_yoast_panel_tab' ] );
162 - }
163 - else {
164 - $this->add_yoast_panel_tab();
165 - }
166 - \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 );
167 182 }
168 183
169 184 /**
170 185 * Initializes the integration.
@@ -177,35 +192,43 @@
177 192 $this->render_hidden_fields();
178 193 }
179 194
180 195 /**
181 - * Register a panel tab slug, in order to allow adding controls to this tab.
196 + * Start capturing buffer.
197 + *
198 + * @return void
182 199 */
183 - public function add_yoast_panel_tab() {
184 - Controls_Manager::add_tab( $this::YOAST_TAB, \__( 'Yoast SEO', 'wordpress-seo' ) );
200 + public function start_output_buffering() {
201 + \ob_start();
185 202 }
186 203
187 204 /**
188 - * Register additional document controls.
205 + * Injects the Yoast SEO tab into the Elements panel of the Elementor editor.
189 206 *
190 - * @param PageBase $document The PageBase document.
207 + * @return void
191 208 */
192 - public function register_document_controls( $document ) {
193 - // PageBase is the base class for documents like `post` `page` and etc.
194 - 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 ) ) {
195 214 return;
196 215 }
197 216
198 - // This is needed to get the tab to appear, but will be overwritten in the JavaScript.
199 - $document->start_controls_section(
200 - 'yoast_temporary_section',
201 - [
202 - 'label' => \__( 'Yoast SEO', 'wordpress-seo' ),
203 - 'tab' => self::YOAST_TAB,
204 - ]
205 - );
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}>';
206 219
207 - $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;
208 231 }
209 232
210 233 // Below is mostly copied from `class-metabox.php`. That constructor has side-effects we do not need.
211 234
@@ -211,14 +234,14 @@
211 234
212 235 /**
213 236 * Determines whether the metabox should be shown for the passed identifier.
214 237 *
215 - * 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.
216 239 *
217 240 * @param string|null $identifier The identifier to check.
218 241 * @param string $type The type of object to check. Defaults to post_type.
219 242 *
220 - * @return bool Whether or not the metabox should be displayed.
243 + * @return bool Whether the metabox should be displayed.
221 244 */
222 245 public function display_metabox( $identifier = null, $type = 'post_type' ) {
223 246 return WPSEO_Utils::is_metabox_active( $identifier, $type );
224 247 }
@@ -234,14 +257,23 @@
234 257 */
235 258 public function save_postdata() {
236 259 global $post;
237 260
238 - $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 + }
239 264
240 - if ( ! \current_user_can( 'manage_options' ) ) {
241 - \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 );
242 270 }
243 271
272 + if ( ! \current_user_can( 'edit_post', $post_id ) ) {
273 + \wp_send_json_error( 'Forbidden', 403 );
274 + }
275 +
244 276 \check_ajax_referer( 'wpseo_elementor_save', '_wpseo_elementor_nonce' );
245 277
246 278 // Bail if this is a multisite installation and the site has been switched.
247 279 if ( \is_multisite() && \ms_is_switched() ) {
@@ -274,9 +306,9 @@
274 306 $meta_boxes,
275 307 WPSEO_Meta::get_meta_field_defs( 'general', $post->post_type ),
276 308 WPSEO_Meta::get_meta_field_defs( 'advanced', $post->post_type ),
277 309 $social_fields,
278 - WPSEO_Meta::get_meta_field_defs( 'schema', $post->post_type )
310 + WPSEO_Meta::get_meta_field_defs( 'schema', $post->post_type ),
279 311 );
280 312
281 313 foreach ( $meta_boxes as $key => $meta_box ) {
282 314 // If analysis is disabled remove that analysis score value from the DB.
@@ -316,24 +348,25 @@
316 348 WPSEO_Meta::set_value( $key, $data, $post_id );
317 349 }
318 350 }
319 351
320 - // Saving the WP post to save the slug.
321 - $slug = \filter_input( \INPUT_POST, WPSEO_Meta::$form_prefix . 'slug', \FILTER_SANITIZE_STRING );
322 - if ( $post->post_name !== $slug ) {
323 - $post_array = $post->to_array();
324 - $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;
325 357
326 - $save_successful = \wp_insert_post( $post_array );
327 - if ( \is_wp_error( $save_successful ) ) {
328 - \wp_send_json_error( 'Slug not saved', 400 );
329 - }
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 + }
330 362
331 - // Update the post object to ensure we have the actual slug.
332 - // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Updating the post is needed to get the current slug.
333 - $post = \get_post( $post_id );
334 - if ( ! \is_object( $post ) ) {
335 - \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 + }
336 369 }
337 370 }
338 371
339 372 \do_action( 'wpseo_saved_postdata' );
@@ -357,8 +390,12 @@
357 390 if ( $key === 'content_score' && ! $this->readability_analysis->is_enabled() ) {
358 391 return true;
359 392 }
360 393
394 + if ( $key === 'inclusive_language_score' && ! $this->inclusive_language_analysis->is_enabled() ) {
395 + return true;
396 + }
397 +
361 398 return false;
362 399 }
363 400
364 401 /**
@@ -368,9 +405,14 @@
368 405 */
369 406 public function enqueue() {
370 407 $post_id = \get_queried_object_id();
371 408 if ( empty( $post_id ) ) {
372 - $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 + }
373 415 }
374 416
375 417 if ( $post_id !== 0 ) {
376 418 // Enqueue files needed for upload functionality.
@@ -378,17 +420,25 @@
378 420 }
379 421
380 422 $this->asset_manager->enqueue_style( 'admin-global' );
381 423 $this->asset_manager->enqueue_style( 'metabox-css' );
382 - $this->asset_manager->enqueue_style( 'scoring' );
383 - $this->asset_manager->enqueue_style( 'select2' );
424 + if ( $this->readability_analysis->is_enabled() ) {
425 + $this->asset_manager->enqueue_style( 'scoring' );
426 + }
384 427 $this->asset_manager->enqueue_style( 'monorepo' );
385 428 $this->asset_manager->enqueue_style( 'admin-css' );
429 + $this->asset_manager->enqueue_style( 'ai-generator' );
386 430 $this->asset_manager->enqueue_style( 'elementor' );
387 431
388 432 $this->asset_manager->enqueue_script( 'admin-global' );
389 433 $this->asset_manager->enqueue_script( 'elementor' );
390 434
435 + $is_v4_atomic = $this->is_elementor_v4_atomic_active();
436 +
437 + if ( $is_v4_atomic ) {
438 + $this->asset_manager->enqueue_script( 'elementor-v4' );
439 + }
440 +
391 441 $this->asset_manager->localize_script( 'elementor', 'wpseoAdminGlobalL10n', \YoastSEO()->helpers->wincher->get_admin_global_links() );
392 442 $this->asset_manager->localize_script( 'elementor', 'wpseoAdminL10n', WPSEO_Utils::get_admin_l10n() );
393 443 $this->asset_manager->localize_script( 'elementor', 'wpseoFeaturesL10n', WPSEO_Utils::retrieve_enabled_features() );
394 444
@@ -393,9 +443,8 @@
393 443 $this->asset_manager->localize_script( 'elementor', 'wpseoFeaturesL10n', WPSEO_Utils::retrieve_enabled_features() );
394 444
395 445 $plugins_script_data = [
396 446 'replaceVars' => [
397 - 'no_parent_text' => \__( '(no parent)', 'wordpress-seo' ),
398 447 'replace_vars' => $this->get_replace_vars(),
399 448 'recommended_replace_vars' => $this->get_recommended_replace_vars(),
400 449 'hidden_replace_vars' => $this->get_hidden_replace_vars(),
401 450 'scope' => $this->determine_scope(),
@@ -401,10 +450,10 @@
401 450 'scope' => $this->determine_scope(),
402 451 'has_taxonomies' => $this->current_post_type_has_taxonomies(),
403 452 ],
404 453 'shortcodes' => [
454 + 'wpseo_shortcode_tags' => $this->get_valid_shortcode_tags(),
405 455 'wpseo_filter_shortcodes_nonce' => \wp_create_nonce( 'wpseo-filter-shortcodes' ),
406 - 'wpseo_shortcode_tags' => $this->get_valid_shortcode_tags(),
407 456 ],
408 457 ];
409 458
410 459 $worker_script_data = [
@@ -415,40 +464,93 @@
415 464 // We need to make the feature flags separately available inside of the analysis web worker.
416 465 'enabled_features' => WPSEO_Utils::retrieve_enabled_features(),
417 466 ];
418 467
419 - $alert_dismissal_action = \YoastSEO()->classes->get( Alert_Dismissal_Action::class );
420 - $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;
421 472
422 473 $script_data = [
423 - 'media' => [ 'choose_image' => \__( 'Use Image', 'wordpress-seo' ) ],
424 - 'metabox' => $this->get_metabox_script_data(),
425 - 'userLanguageCode' => WPSEO_Language_Utils::get_language( \get_user_locale() ),
426 - 'isPost' => true,
427 - 'isBlockEditor' => WP_Screen::get()->is_block_editor(),
428 - 'isElementorEditor' => true,
429 - 'postStatus' => get_post_status( $post_id ),
430 - 'analysis' => [
431 - 'plugins' => $plugins_script_data,
432 - 'worker' => $worker_script_data,
433 - '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,
434 485 ],
435 - 'dismissedAlerts' => $dismissed_alerts,
486 + 'usedKeywordsNonce' => \wp_create_nonce( 'wpseo-keyword-usage-and-post-types' ),
487 + 'isFrontPage' => $is_front_page,
436 488 ];
437 489
438 - if ( \post_type_supports( $this->get_metabox_post()->post_type, 'thumbnail' ) ) {
439 - $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 );
440 499
441 - $script_data['featuredImage'] = [
442 - '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' ),
443 - ];
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;
444 514 }
445 515
446 - $this->asset_manager->localize_script( 'elementor', 'wpseoScriptData', $script_data );
447 - $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', '>=' );
448 522 }
449 523
450 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 + /**
451 553 * Renders the metabox hidden fields.
452 554 *
453 555 * @return void
454 556 */
@@ -456,9 +558,9 @@
456 558 // Wrap in a form with an action and post_id for the submit.
457 559 \printf(
458 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" />',
459 561 \esc_url( \admin_url( 'admin-ajax.php' ) ),
460 - \esc_attr( $this->get_metabox_post()->ID )
562 + \esc_attr( $this->get_metabox_post()->ID ),
461 563 );
462 564
463 565 \wp_nonce_field( 'wpseo_elementor_save', '_wpseo_elementor_nonce' );
464 566 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Reason: Meta_Fields_Presenter->present is considered safe.
@@ -479,9 +581,14 @@
479 581
480 582 \printf(
481 583 '<input type="hidden" id="%1$s" name="%1$s" value="%2$s" />',
482 584 \esc_attr( WPSEO_Meta::$form_prefix . 'slug' ),
483 - \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 ),
484 591 );
485 592
486 593 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output should be escaped in the filter.
487 594 echo \apply_filters( 'wpseo_elementor_hidden_fields', '' );
@@ -489,68 +596,53 @@
489 596 echo '</form>';
490 597 }
491 598
492 599 /**
493 - * Returns the slug for the post being edited.
600 + * Returns post in metabox context.
494 601 *
495 - * @return string
602 + * @return WP_Post|null
496 603 */
497 - protected function get_post_slug() {
498 - $post = $this->get_metabox_post();
499 -
500 - // In case get_metabox_post returns null for whatever reason.
501 - if ( ! $post instanceof WP_Post ) {
502 - return '';
604 + protected function get_metabox_post() {
605 + if ( $this->post !== null ) {
606 + return $this->post;
503 607 }
504 608
505 - // Drafts might not have a post_name unless the slug has been manually changed.
506 - // In this case we get it using get_sample_permalink.
507 - if ( ! $post->post_name ) {
508 - $sample = \get_sample_permalink( $post );
609 + $this->post = $this->request_post->get_post();
509 610
510 - // Since get_sample_permalink runs through filters, ensure that it has the expected return value.
511 - if ( is_array( $sample ) && count( $sample ) === 2 && is_string( $sample[1] ) ) {
512 - return $sample[1];
513 - }
514 - }
515 -
516 - return $post->post_name;
611 + return $this->post;
517 612 }
518 613
519 614 /**
520 - * Returns post in metabox context.
615 + * Passes variables to js for use with the post-scraper.
521 616 *
522 - * @return WP_Post|null
617 + * @param string $permalink The permalink.
618 + *
619 + * @return array
523 620 */
524 - protected function get_metabox_post() {
525 - if ( $this->post !== null ) {
526 - return $this->post;
527 - }
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 + );
528 625
529 - $post = \filter_input( \INPUT_GET, 'post' );
530 - if ( ! empty( $post ) ) {
531 - $post_id = (int) WPSEO_Utils::validate_int( $post );
626 + $values = $post_formatter->get_values();
532 627
533 - $this->post = \get_post( $post_id );
534 -
535 - 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;
536 632 }
537 633
538 - if ( isset( $GLOBALS['post'] ) ) {
539 - $this->post = $GLOBALS['post'];
634 + $values['elementorMarkerStatus'] = $this->is_highlighting_available() ? 'enabled' : 'hidden';
540 635
541 - return $this->post;
542 - }
543 -
544 - return null;
636 + return $values;
545 637 }
546 638
547 639 /**
548 - * Passes variables to js for use with the post-scraper.
640 + * Gets the permalink.
549 641 *
550 - * @return array
642 + * @return string
551 643 */
552 - protected function get_metabox_script_data() {
644 + protected function get_permalink(): string {
553 645 $permalink = '';
554 646
555 647 if ( \is_object( $this->get_metabox_post() ) ) {
556 648 $permalink = \get_sample_permalink( $this->get_metabox_post()->ID );
@@ -556,21 +648,23 @@
556 648 $permalink = \get_sample_permalink( $this->get_metabox_post()->ID );
557 649 $permalink = $permalink[0];
558 650 }
559 651
560 - $post_formatter = new WPSEO_Metabox_Formatter(
561 - new WPSEO_Post_Metabox_Formatter( $this->get_metabox_post(), [], $permalink )
562 - );
652 + return $permalink;
653 + }
563 654
564 - $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();
565 665
566 - /** This filter is documented in admin/filters/class-cornerstone-filter.php. */
567 - $post_types = \apply_filters( 'wpseo_cornerstone_post_types', \YoastSEO()->helpers->post_type->get_accessible_post_types() );
568 - if ( $values['cornerstoneActive'] && ! \in_array( $this->get_metabox_post()->post_type, $post_types, true ) ) {
569 - $values['cornerstoneActive'] = false;
570 - }
571 -
572 - return $values;
666 + return ! $is_premium || \version_compare( $premium_version, '21.8-RC0', '>=' );
573 667 }
574 668
575 669 /**
576 670 * Prepares the replace vars for localization.
@@ -703,11 +797,27 @@
703 797 }
704 798
705 799 $custom_fields = \get_post_custom( $post->ID );
706 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];
707 812 foreach ( $custom_fields as $custom_field_name => $custom_field ) {
708 813 // Skip private custom fields.
709 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 ) ) {
710 820 continue;
711 821 }
712 822
713 823 // Skip custom field values that are serialized.