PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 27.5
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v27.5
28.6 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 All 130 releases
wordpress-seo / admin / metabox / class-metabox.php

class-metabox.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 27.5, at admin/metabox/class-metabox.php

1,179 lines 38.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WPSEO plugin file.
4 *
5 * @package WPSEO\Admin
6 */
7
8 use Yoast\WP\SEO\Editors\Application\Site\Website_Information_Repository;
9 use Yoast\WP\SEO\Presenters\Admin\Alert_Presenter;
10 use Yoast\WP\SEO\Presenters\Admin\Meta_Fields_Presenter;
11
12 /**
13 * This class generates the metabox on the edit post / page as well as contains all page analysis functionality.
14 */
15 class WPSEO_Metabox extends WPSEO_Meta {
16
17 /**
18 * Whether the social tab is enabled.
19 *
20 * @var bool
21 */
22 private $social_is_enabled;
23
24 /**
25 * Helper to determine whether the SEO analysis is enabled.
26 *
27 * @var WPSEO_Metabox_Analysis_SEO
28 */
29 protected $seo_analysis;
30
31 /**
32 * Helper to determine whether the readability analysis is enabled.
33 *
34 * @var WPSEO_Metabox_Analysis_Readability
35 */
36 protected $readability_analysis;
37
38 /**
39 * Helper to determine whether the inclusive language analysis is enabled.
40 *
41 * @var WPSEO_Metabox_Analysis_Inclusive_Language
42 */
43 protected $inclusive_language_analysis;
44
45 /**
46 * The metabox editor object.
47 *
48 * @var WPSEO_Metabox_Editor
49 */
50 protected $editor;
51
52 /**
53 * The Metabox post.
54 *
55 * @var WP_Post|null
56 */
57 protected $post = null;
58
59 /**
60 * Whether the advanced metadata is enabled.
61 *
62 * @var bool
63 */
64 protected $is_advanced_metadata_enabled;
65
66 /**
67 * Class constructor.
68 */
69 public function __construct() {
70 if ( $this->is_internet_explorer() ) {
71 add_action( 'add_meta_boxes', [ $this, 'internet_explorer_metabox' ] );
72
73 return;
74 }
75
76 add_action( 'add_meta_boxes', [ $this, 'add_meta_box' ] );
77 add_action( 'admin_enqueue_scripts', [ $this, 'enqueue' ] );
78 add_action( 'wp_insert_post', [ $this, 'save_postdata' ] );
79 add_action( 'edit_attachment', [ $this, 'save_postdata' ] );
80 add_action( 'add_attachment', [ $this, 'save_postdata' ] );
81
82 $this->social_is_enabled = WPSEO_Options::get( 'opengraph', false, [ 'wpseo_social' ] ) || WPSEO_Options::get( 'twitter', false, [ 'wpseo_social' ] );
83 $this->is_advanced_metadata_enabled = WPSEO_Capability_Utils::current_user_can( 'wpseo_edit_advanced_metadata' ) || WPSEO_Options::get( 'disableadvanced_meta', null, [ 'wpseo' ] ) === false;
84
85 $this->seo_analysis = new WPSEO_Metabox_Analysis_SEO();
86 $this->readability_analysis = new WPSEO_Metabox_Analysis_Readability();
87 $this->inclusive_language_analysis = new WPSEO_Metabox_Analysis_Inclusive_Language();
88 }
89
90 /**
91 * Checks whether the request comes from an IE 11 browser.
92 *
93 * @return bool Whether the request comes from an IE 11 browser.
94 */
95 public static function is_internet_explorer() {
96 if ( empty( $_SERVER['HTTP_USER_AGENT'] ) ) {
97 return false;
98 }
99
100 $user_agent = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) );
101
102 if ( stripos( $user_agent, 'Trident/7.0' ) === false ) {
103 return false;
104 }
105
106 return true;
107 }
108
109 /**
110 * Adds an alternative metabox for internet explorer users.
111 *
112 * @return void
113 */
114 public function internet_explorer_metabox() {
115 $post_types = WPSEO_Post_Type::get_accessible_post_types();
116 $post_types = array_filter( $post_types, [ $this, 'display_metabox' ] );
117
118 if ( ! is_array( $post_types ) || $post_types === [] ) {
119 return;
120 }
121
122 $product_title = $this->get_product_title();
123
124 foreach ( $post_types as $post_type ) {
125 add_filter( "postbox_classes_{$post_type}_wpseo_meta", [ $this, 'wpseo_metabox_class' ] );
126
127 add_meta_box(
128 'wpseo_meta',
129 $product_title,
130 [ $this, 'render_internet_explorer_notice' ],
131 $post_type,
132 'normal',
133 apply_filters( 'wpseo_metabox_prio', 'high' ),
134 [ '__block_editor_compatible_meta_box' => true ],
135 );
136 }
137 }
138
139 /**
140 * Renders the content for the internet explorer metabox.
141 *
142 * @return void
143 */
144 public function render_internet_explorer_notice() {
145 $content = sprintf(
146 /* translators: 1: Link start tag to the Firefox website, 2: Link start tag to the Chrome website, 3: Link start tag to the Edge website, 4: Link closing tag. */
147 esc_html__( 'The browser you are currently using is unfortunately rather dated. Since we strive to give you the best experience possible, we no longer support this browser. Instead, please use %1$sFirefox%4$s, %2$sChrome%4$s or %3$sMicrosoft Edge%4$s.', 'wordpress-seo' ),
148 '<a href="https://www.mozilla.org/firefox/new/">',
149 '<a href="https://www.google.com/chrome/">',
150 '<a href="https://www.microsoft.com/windows/microsoft-edge">',
151 '</a>',
152 );
153
154 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output escaped above.
155 echo new Alert_Presenter( $content );
156 }
157
158 /**
159 * Translates text strings for use in the meta box.
160 *
161 * IMPORTANT: if you want to add a new string (option) somewhere, make sure you add that array key to
162 * the main meta box definition array in the class WPSEO_Meta() as well!!!!
163 *
164 * @deprecated 23.5
165 * @codeCoverageIgnore
166 *
167 * @return void
168 */
169 public static function translate_meta_boxes() {
170 _deprecated_function( __METHOD__, 'Yoast SEO 23.5' );
171
172 WPSEO_Meta::$meta_fields['general']['title']['title'] = __( 'SEO title', 'wordpress-seo' );
173 WPSEO_Meta::$meta_fields['general']['metadesc']['title'] = __( 'Meta description', 'wordpress-seo' );
174
175 /* translators: %s expands to the post type name. */
176 WPSEO_Meta::$meta_fields['advanced']['meta-robots-noindex']['title'] = __( 'Allow search engines to show this %s in search results?', 'wordpress-seo' );
177 if ( (string) get_option( 'blog_public' ) === '0' ) {
178 WPSEO_Meta::$meta_fields['advanced']['meta-robots-noindex']['description'] = '<span class="error-message">' . __( 'Warning: even though you can set the meta robots setting here, the entire site is set to noindex in the sitewide privacy settings, so these settings won\'t have an effect.', 'wordpress-seo' ) . '</span>';
179 }
180 /* translators: %1$s expands to Yes or No, %2$s expands to the post type name.*/
181 WPSEO_Meta::$meta_fields['advanced']['meta-robots-noindex']['options']['0'] = __( 'Default for %2$s, currently: %1$s', 'wordpress-seo' );
182 WPSEO_Meta::$meta_fields['advanced']['meta-robots-noindex']['options']['2'] = __( 'Yes', 'wordpress-seo' );
183 WPSEO_Meta::$meta_fields['advanced']['meta-robots-noindex']['options']['1'] = __( 'No', 'wordpress-seo' );
184
185 /* translators: %1$s expands to the post type name.*/
186 WPSEO_Meta::$meta_fields['advanced']['meta-robots-nofollow']['title'] = __( 'Should search engines follow links on this %1$s?', 'wordpress-seo' );
187 WPSEO_Meta::$meta_fields['advanced']['meta-robots-nofollow']['options']['0'] = __( 'Yes', 'wordpress-seo' );
188 WPSEO_Meta::$meta_fields['advanced']['meta-robots-nofollow']['options']['1'] = __( 'No', 'wordpress-seo' );
189
190 WPSEO_Meta::$meta_fields['advanced']['meta-robots-adv']['title'] = __( 'Meta robots advanced', 'wordpress-seo' );
191 WPSEO_Meta::$meta_fields['advanced']['meta-robots-adv']['description'] = __( 'If you want to apply advanced <code>meta</code> robots settings for this page, please define them in the following field.', 'wordpress-seo' );
192 WPSEO_Meta::$meta_fields['advanced']['meta-robots-adv']['options']['noimageindex'] = __( 'No Image Index', 'wordpress-seo' );
193 WPSEO_Meta::$meta_fields['advanced']['meta-robots-adv']['options']['noarchive'] = __( 'No Archive', 'wordpress-seo' );
194 WPSEO_Meta::$meta_fields['advanced']['meta-robots-adv']['options']['nosnippet'] = __( 'No Snippet', 'wordpress-seo' );
195
196 WPSEO_Meta::$meta_fields['advanced']['bctitle']['title'] = __( 'Breadcrumbs Title', 'wordpress-seo' );
197 WPSEO_Meta::$meta_fields['advanced']['bctitle']['description'] = __( 'Title to use for this page in breadcrumb paths', 'wordpress-seo' );
198
199 WPSEO_Meta::$meta_fields['advanced']['canonical']['title'] = __( 'Canonical URL', 'wordpress-seo' );
200
201 WPSEO_Meta::$meta_fields['advanced']['canonical']['description'] = sprintf(
202 /* translators: 1: link open tag; 2: link close tag. */
203 __( 'The canonical URL that this page should point to. Leave empty to default to permalink. %1$sCross domain canonical%2$s supported too.', 'wordpress-seo' ),
204 '<a href="https://googlewebmastercentral.blogspot.com/2009/12/handling-legitimate-cross-domain.html" target="_blank" rel="noopener">',
205 WPSEO_Admin_Utils::get_new_tab_message() . '</a>',
206 );
207
208 WPSEO_Meta::$meta_fields['advanced']['redirect']['title'] = __( '301 Redirect', 'wordpress-seo' );
209 WPSEO_Meta::$meta_fields['advanced']['redirect']['description'] = __( 'The URL that this page should redirect to.', 'wordpress-seo' );
210
211 do_action_deprecated( 'wpseo_tab_translate', [], 'Yoast SEO 23.5', '', 'WPSEO_Metabox::translate_meta_boxes is deprecated.' );
212 }
213
214 /**
215 * Determines whether the metabox should be shown for the passed identifier.
216 *
217 * By default the check is done for post types, but can also be used for taxonomies.
218 *
219 * @param string|null $identifier The identifier to check.
220 * @param string $type The type of object to check. Defaults to post_type.
221 *
222 * @return bool Whether or not the metabox should be displayed.
223 */
224 public function display_metabox( $identifier = null, $type = 'post_type' ) {
225 return WPSEO_Utils::is_metabox_active( $identifier, $type );
226 }
227
228 /**
229 * Adds the Yoast SEO meta box to the edit boxes in the edit post, page,
230 * attachment, and custom post types pages.
231 *
232 * @return void
233 */
234 public function add_meta_box() {
235 $post_types = WPSEO_Post_Type::get_accessible_post_types();
236 $post_types = array_filter( $post_types, [ $this, 'display_metabox' ] );
237
238 if ( ! is_array( $post_types ) || $post_types === [] ) {
239 return;
240 }
241
242 $product_title = $this->get_product_title();
243
244 foreach ( $post_types as $post_type ) {
245 add_filter( "postbox_classes_{$post_type}_wpseo_meta", [ $this, 'wpseo_metabox_class' ] );
246
247 add_meta_box(
248 'wpseo_meta',
249 $product_title,
250 [ $this, 'meta_box' ],
251 $post_type,
252 'normal',
253 apply_filters( 'wpseo_metabox_prio', 'high' ),
254 [ '__block_editor_compatible_meta_box' => true ],
255 );
256 }
257 }
258
259 /**
260 * Adds CSS classes to the meta box.
261 *
262 * @param string[] $classes An array of postbox CSS classes.
263 *
264 * @return string[] List of classes that will be applied to the editbox container.
265 */
266 public function wpseo_metabox_class( $classes ) {
267 $classes[] = 'yoast wpseo-metabox';
268
269 return $classes;
270 }
271
272 /**
273 * Passes variables to js for use with the post-scraper.
274 *
275 * @return array<string, string|array<string|int|bool>|bool|int>
276 */
277 public function get_metabox_script_data() {
278 $permalink = $this->get_permalink();
279
280 $post_formatter = new WPSEO_Metabox_Formatter(
281 new WPSEO_Post_Metabox_Formatter( $this->get_metabox_post(), [], $permalink ),
282 );
283
284 $values = $post_formatter->get_values();
285 /** This filter is documented in admin/filters/class-cornerstone-filter.php. */
286 $post_types = apply_filters( 'wpseo_cornerstone_post_types', WPSEO_Post_Type::get_accessible_post_types() );
287 if ( $values['cornerstoneActive'] && ! in_array( $this->get_metabox_post()->post_type, $post_types, true ) ) {
288 $values['cornerstoneActive'] = false;
289 }
290
291 if ( $values['semrushIntegrationActive'] && $this->post->post_type === 'attachment' ) {
292 $values['semrushIntegrationActive'] = 0;
293 }
294
295 if ( $values['wincherIntegrationActive'] && $this->post->post_type === 'attachment' ) {
296 $values['wincherIntegrationActive'] = 0;
297 }
298
299 return $values;
300 }
301
302 /**
303 * Determines whether or not the current post type has registered taxonomies.
304 *
305 * @return bool Whether the current post type has taxonomies.
306 */
307 private function current_post_type_has_taxonomies() {
308 $post_taxonomies = get_object_taxonomies( get_post_type() );
309
310 return ! empty( $post_taxonomies );
311 }
312
313 /**
314 * Determines the scope based on the post type.
315 * This can be used by the replacevar plugin to determine if a replacement needs to be executed.
316 *
317 * @return string String describing the current scope.
318 */
319 private function determine_scope() {
320 if ( $this->get_metabox_post()->post_type === 'page' ) {
321 return 'page';
322 }
323
324 return 'post';
325 }
326
327 /**
328 * Outputs the meta box.
329 *
330 * @return void
331 */
332 public function meta_box() {
333 $this->render_hidden_fields();
334 $this->render_tabs();
335 }
336
337 /**
338 * Renders the metabox hidden fields.
339 *
340 * @return void
341 */
342 protected function render_hidden_fields() {
343 wp_nonce_field( 'yoast_free_metabox', 'yoast_free_metabox_nonce' );
344
345 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output escaped in class.
346 echo new Meta_Fields_Presenter( $this->get_metabox_post(), 'general' );
347
348 if ( $this->is_advanced_metadata_enabled ) {
349 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output escaped in class.
350 echo new Meta_Fields_Presenter( $this->get_metabox_post(), 'advanced' );
351 }
352
353 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output escaped in class.
354 echo new Meta_Fields_Presenter( $this->get_metabox_post(), 'schema', $this->get_metabox_post()->post_type );
355
356 if ( $this->social_is_enabled ) {
357 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output escaped in class.
358 echo new Meta_Fields_Presenter( $this->get_metabox_post(), 'social' );
359 }
360
361 /**
362 * Filter: 'wpseo_content_meta_section_content' - Allow filtering the metabox content before outputting.
363 *
364 * @param string $post_content The metabox content string.
365 */
366 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output should be escaped in the filter.
367 echo apply_filters( 'wpseo_content_meta_section_content', '' );
368 }
369
370 /**
371 * Renders the metabox tabs.
372 *
373 * @return void
374 */
375 protected function render_tabs() {
376 echo '<div class="wpseo-metabox-content">';
377 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Reason: $this->get_product_title() returns a hard-coded string.
378 printf( '<div class="wpseo-metabox-menu"><ul role="tablist" class="yoast-aria-tabs" aria-label="%s">', $this->get_product_title() );
379
380 $tabs = $this->get_tabs();
381
382 foreach ( $tabs as $tab ) {
383 if ( $tab->name === 'premium' ) {
384 continue;
385 }
386
387 $tab->display_link();
388 }
389
390 echo '</ul></div>';
391
392 foreach ( $tabs as $tab ) {
393 $tab->display_content();
394 }
395
396 echo '</div>';
397 }
398
399 /**
400 * Returns the relevant metabox tabs for the current view.
401 *
402 * @return WPSEO_Metabox_Section[]
403 */
404 private function get_tabs() {
405 $tabs = [];
406
407 $label = __( 'SEO', 'wordpress-seo' );
408 if ( $this->seo_analysis->is_enabled() ) {
409 $label = '<span class="wpseo-score-icon-container" id="wpseo-seo-score-icon"></span>' . $label;
410 }
411 $tabs[] = new WPSEO_Metabox_Section_React( 'content', $label );
412
413 if ( $this->readability_analysis->is_enabled() ) {
414 $tabs[] = new WPSEO_Metabox_Section_Readability();
415 }
416
417 if ( $this->inclusive_language_analysis->is_enabled() ) {
418 $tabs[] = new WPSEO_Metabox_Section_Inclusive_Language();
419 }
420
421 if ( $this->is_advanced_metadata_enabled ) {
422 $tabs[] = new WPSEO_Metabox_Section_React(
423 'schema',
424 '<span class="wpseo-schema-icon"></span>' . __( 'Schema', 'wordpress-seo' ),
425 '',
426 );
427 }
428
429 if ( $this->social_is_enabled ) {
430 $tabs[] = new WPSEO_Metabox_Section_React(
431 'social',
432 '<span class="dashicons dashicons-share"></span>' . __( 'Social', 'wordpress-seo' ),
433 '',
434 [
435 'html_after' => '<div id="wpseo-section-social"></div>',
436 ],
437 );
438 }
439
440 $tabs = array_merge( $tabs, $this->get_additional_tabs() );
441
442 return $tabs;
443 }
444
445 /**
446 * Returns the metabox tabs that have been added by other plugins.
447 *
448 * @return WPSEO_Metabox_Section_Additional[]
449 */
450 protected function get_additional_tabs() {
451 $tabs = [];
452
453 /**
454 * Private filter: 'yoast_free_additional_metabox_sections'.
455 *
456 * Meant for internal use only. Allows adding additional tabs to the Yoast SEO metabox.
457 *
458 * @since 11.9
459 *
460 * @param array[] $tabs {
461 * An array of arrays with tab specifications.
462 *
463 * @type array $tab {
464 * A tab specification.
465 *
466 * @type string $name The name of the tab. Used in the HTML IDs, href and aria properties.
467 * @type string $link_content The content of the tab link.
468 * @type string $content The content of the tab.
469 * @type array $options {
470 * Optional. Extra options.
471 *
472 * @type string $link_class Optional. The class for the tab link.
473 * @type string $link_aria_label Optional. The aria label of the tab link.
474 * }
475 * }
476 * }
477 */
478 $requested_tabs = apply_filters( 'yoast_free_additional_metabox_sections', [] );
479
480 foreach ( $requested_tabs as $tab ) {
481 if ( is_array( $tab ) && array_key_exists( 'name', $tab ) && array_key_exists( 'link_content', $tab ) && array_key_exists( 'content', $tab ) ) {
482 $options = array_key_exists( 'options', $tab ) ? $tab['options'] : [];
483 $tabs[] = new WPSEO_Metabox_Section_Additional(
484 $tab['name'],
485 $tab['link_content'],
486 $tab['content'],
487 $options,
488 );
489 }
490 }
491
492 return $tabs;
493 }
494
495 /**
496 * Adds a line in the meta box.
497 *
498 * @deprecated 23.5
499 * @codeCoverageIgnore
500 *
501 * @param string[] $meta_field_def Contains the vars based on which output is generated.
502 * @param string $key Internal key (without prefix).
503 *
504 * @return string
505 */
506 public function do_meta_box( $meta_field_def, $key = '' ) {
507 _deprecated_function( __METHOD__, 'Yoast SEO 23.5' );
508
509 $content = '';
510 $esc_form_key = esc_attr( WPSEO_Meta::$form_prefix . $key );
511 $meta_value = WPSEO_Meta::get_value( $key, $this->get_metabox_post()->ID );
512
513 $class = '';
514 if ( isset( $meta_field_def['class'] ) && $meta_field_def['class'] !== '' ) {
515 $class = ' ' . $meta_field_def['class'];
516 }
517
518 $placeholder = '';
519 if ( isset( $meta_field_def['placeholder'] ) && $meta_field_def['placeholder'] !== '' ) {
520 $placeholder = $meta_field_def['placeholder'];
521 }
522
523 $aria_describedby = '';
524 $description = '';
525 if ( isset( $meta_field_def['description'] ) ) {
526 $aria_describedby = ' aria-describedby="' . $esc_form_key . '-desc"';
527 $description = '<p id="' . $esc_form_key . '-desc" class="yoast-metabox__description">' . $meta_field_def['description'] . '</p>';
528 }
529
530 // Add a hide_on_pages option that returns nothing when the field is rendered on a page.
531 if ( isset( $meta_field_def['hide_on_pages'] ) && $meta_field_def['hide_on_pages'] && get_post_type() === 'page' ) {
532 return '';
533 }
534
535 switch ( $meta_field_def['type'] ) {
536 case 'text':
537 $ac = '';
538 if ( isset( $meta_field_def['autocomplete'] ) && $meta_field_def['autocomplete'] === false ) {
539 $ac = 'autocomplete="off" ';
540 }
541 if ( $placeholder !== '' ) {
542 $placeholder = ' placeholder="' . esc_attr( $placeholder ) . '"';
543 }
544 $content .= '<input type="text"' . $placeholder . ' id="' . $esc_form_key . '" ' . $ac . 'name="' . $esc_form_key . '" value="' . esc_attr( $meta_value ) . '" class="large-text' . $class . '"' . $aria_describedby . '/>';
545 break;
546
547 case 'url':
548 if ( $placeholder !== '' ) {
549 $placeholder = ' placeholder="' . esc_attr( $placeholder ) . '"';
550 }
551 $content .= '<input type="url"' . $placeholder . ' id="' . $esc_form_key . '" name="' . $esc_form_key . '" value="' . esc_attr( urldecode( $meta_value ) ) . '" class="large-text' . $class . '"' . $aria_describedby . '/>';
552 break;
553
554 case 'textarea':
555 $rows = 3;
556 if ( isset( $meta_field_def['rows'] ) && $meta_field_def['rows'] > 0 ) {
557 $rows = $meta_field_def['rows'];
558 }
559 $content .= '<textarea class="large-text' . $class . '" rows="' . esc_attr( $rows ) . '" id="' . $esc_form_key . '" name="' . $esc_form_key . '"' . $aria_describedby . '>' . esc_textarea( $meta_value ) . '</textarea>';
560 break;
561
562 case 'hidden':
563 $default = '';
564 if ( isset( $meta_field_def['default'] ) ) {
565 $default = sprintf( ' data-default="%s"', esc_attr( $meta_field_def['default'] ) );
566 }
567 $content .= '<input type="hidden" id="' . $esc_form_key . '" name="' . $esc_form_key . '" value="' . esc_attr( $meta_value ) . '"' . $default . '/>' . "\n";
568 break;
569 case 'select':
570 if ( isset( $meta_field_def['options'] ) && is_array( $meta_field_def['options'] ) && $meta_field_def['options'] !== [] ) {
571 $content .= '<select name="' . $esc_form_key . '" id="' . $esc_form_key . '" class="yoast' . $class . '">';
572 foreach ( $meta_field_def['options'] as $val => $option ) {
573 $selected = selected( $meta_value, $val, false );
574 $content .= '<option ' . $selected . ' value="' . esc_attr( $val ) . '">' . esc_html( $option ) . '</option>';
575 }
576 unset( $val, $option, $selected );
577 $content .= '</select>';
578 }
579 break;
580
581 case 'multiselect':
582 if ( isset( $meta_field_def['options'] ) && is_array( $meta_field_def['options'] ) && $meta_field_def['options'] !== [] ) {
583
584 // Set $meta_value as $selected_arr.
585 $selected_arr = $meta_value;
586
587 // If the multiselect field is 'meta-robots-adv' we should explode on ,.
588 if ( $key === 'meta-robots-adv' ) {
589 $selected_arr = explode( ',', $meta_value );
590 }
591
592 if ( ! is_array( $selected_arr ) ) {
593 $selected_arr = (array) $selected_arr;
594 }
595
596 $options_count = count( $meta_field_def['options'] );
597
598 $content .= '<select multiple="multiple" size="' . esc_attr( $options_count ) . '" name="' . $esc_form_key . '[]" id="' . $esc_form_key . '" class="yoast' . $class . '"' . $aria_describedby . '>';
599 foreach ( $meta_field_def['options'] as $val => $option ) {
600 $selected = '';
601 if ( in_array( $val, $selected_arr, true ) ) {
602 $selected = ' selected="selected"';
603 }
604 $content .= '<option ' . $selected . ' value="' . esc_attr( $val ) . '">' . esc_html( $option ) . '</option>';
605 }
606 $content .= '</select>';
607 unset( $val, $option, $selected, $selected_arr, $options_count );
608 }
609 break;
610
611 case 'checkbox':
612 $checked = checked( $meta_value, 'on', false );
613 $expl = ( isset( $meta_field_def['expl'] ) ) ? esc_html( $meta_field_def['expl'] ) : '';
614 $content .= '<input type="checkbox" id="' . $esc_form_key . '" name="' . $esc_form_key . '" ' . $checked . ' value="on" class="yoast' . $class . '"' . $aria_describedby . '/> <label for="' . $esc_form_key . '">' . $expl . '</label>';
615 unset( $checked, $expl );
616 break;
617
618 case 'radio':
619 if ( isset( $meta_field_def['options'] ) && is_array( $meta_field_def['options'] ) && $meta_field_def['options'] !== [] ) {
620 foreach ( $meta_field_def['options'] as $val => $option ) {
621 $checked = checked( $meta_value, $val, false );
622 $content .= '<input type="radio" ' . $checked . ' id="' . $esc_form_key . '_' . esc_attr( $val ) . '" name="' . $esc_form_key . '" value="' . esc_attr( $val ) . '"/> <label for="' . $esc_form_key . '_' . esc_attr( $val ) . '">' . esc_html( $option ) . '</label> ';
623 }
624 unset( $val, $option, $checked );
625 }
626 break;
627 }
628
629 $html = '';
630 if ( $content === '' ) {
631 $content = apply_filters_deprecated( 'wpseo_do_meta_box_field_' . $key, [ $content, $meta_value, $esc_form_key, $meta_field_def, $key ], 'Yoast SEO 23.5', '', 'do_meta_box is deprecated' );
632 }
633
634 if ( $content !== '' ) {
635
636 $title = esc_html( $meta_field_def['title'] );
637
638 // By default, use the field title as a label element.
639 $label = '<label for="' . $esc_form_key . '">' . $title . '</label>';
640
641 // Set the inline help and help panel, if any.
642 $help_button = '';
643 $help_panel = '';
644 if ( isset( $meta_field_def['help'] ) && $meta_field_def['help'] !== '' ) {
645 $help = new WPSEO_Admin_Help_Panel( $key, $meta_field_def['help-button'], $meta_field_def['help'] );
646 $help_button = $help->get_button_html();
647 $help_panel = $help->get_panel_html();
648 }
649
650 // If it's a set of radio buttons, output proper fieldset and legend.
651 if ( $meta_field_def['type'] === 'radio' ) {
652 return '<fieldset><legend>' . $title . '</legend>' . $help_button . $help_panel . $content . $description . '</fieldset>';
653 }
654
655 // If it's a single checkbox, ignore the title.
656 if ( $meta_field_def['type'] === 'checkbox' ) {
657 $label = '';
658 }
659
660 // Other meta box content or form fields.
661 if ( $meta_field_def['type'] === 'hidden' ) {
662 $html = $content;
663 }
664 else {
665 $html = $label . $description . $help_button . $help_panel . $content;
666 }
667 }
668
669 return $html;
670 }
671
672 /**
673 * Saves the WP SEO metadata for posts.
674 *
675 * {@internal $_POST parameters are validated via sanitize_post_meta().}}
676 *
677 * @param int $post_id Post ID.
678 *
679 * @return bool|void Boolean false if invalid save post request.
680 */
681 public function save_postdata( $post_id ) {
682 // Bail if this is a multisite installation and the site has been switched.
683 if ( is_multisite() && ms_is_switched() ) {
684 return false;
685 }
686
687 if ( $post_id === null ) {
688 return false;
689 }
690
691 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized in wp_verify_none.
692 if ( ! isset( $_POST['yoast_free_metabox_nonce'] ) || ! wp_verify_nonce( wp_unslash( $_POST['yoast_free_metabox_nonce'] ), 'yoast_free_metabox' ) ) {
693 return false;
694 }
695
696 if ( wp_is_post_revision( $post_id ) ) {
697 $post_id = wp_is_post_revision( $post_id );
698 }
699
700 /**
701 * Determine we're not accidentally updating a different post.
702 * We can't use filter_input here as the ID isn't available at this point, other than in the $_POST data.
703 */
704 if ( ! isset( $_POST['ID'] ) || $post_id !== (int) $_POST['ID'] ) {
705 return false;
706 }
707
708 clean_post_cache( $post_id );
709 $post = get_post( $post_id );
710
711 if ( ! is_object( $post ) ) {
712 // Non-existent post.
713 return false;
714 }
715
716 do_action( 'wpseo_save_compare_data', $post );
717
718 $social_fields = [];
719 if ( $this->social_is_enabled ) {
720 $social_fields = WPSEO_Meta::get_meta_field_defs( 'social' );
721 }
722
723 $meta_boxes = apply_filters( 'wpseo_save_metaboxes', [] );
724 $meta_boxes = array_merge(
725 $meta_boxes,
726 WPSEO_Meta::get_meta_field_defs( 'general', $post->post_type ),
727 WPSEO_Meta::get_meta_field_defs( 'advanced' ),
728 $social_fields,
729 WPSEO_Meta::get_meta_field_defs( 'schema', $post->post_type ),
730 );
731
732 foreach ( $meta_boxes as $key => $meta_box ) {
733
734 // If analysis is disabled remove that analysis score value from the DB.
735 if ( $this->is_meta_value_disabled( $key ) ) {
736 WPSEO_Meta::delete( $key, $post_id );
737 continue;
738 }
739
740 $data = null;
741 $field_name = WPSEO_Meta::$form_prefix . $key;
742
743 if ( $meta_box['type'] === 'checkbox' ) {
744 $data = isset( $_POST[ $field_name ] ) ? 'on' : 'off';
745 }
746 else {
747 if ( isset( $_POST[ $field_name ] ) ) {
748 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- We're preparing to do just that.
749 $data = wp_unslash( $_POST[ $field_name ] );
750
751 // For multi-select.
752 if ( is_array( $data ) ) {
753 $data = array_map( [ 'WPSEO_Utils', 'sanitize_text_field' ], $data );
754 }
755
756 if ( is_string( $data ) ) {
757 $data = ( $key !== 'canonical' ) ? WPSEO_Utils::sanitize_text_field( $data ) : WPSEO_Utils::sanitize_url( $data );
758 }
759 }
760
761 // Reset options when no entry is present with multiselect - only applies to `meta-robots-adv` currently.
762 if ( ! isset( $_POST[ $field_name ] ) && ( $meta_box['type'] === 'multiselect' ) ) {
763 $data = [];
764 }
765 }
766
767 if ( $data !== null ) {
768 WPSEO_Meta::set_value( $key, $data, $post_id );
769 }
770 }
771
772 do_action( 'wpseo_saved_postdata' );
773 }
774
775 /**
776 * Determines if the given meta value key is disabled.
777 *
778 * @param string $key The key of the meta value.
779 *
780 * @return bool Whether the given meta value key is disabled.
781 */
782 public function is_meta_value_disabled( $key ) {
783 if ( $key === 'linkdex' && ! $this->seo_analysis->is_enabled() ) {
784 return true;
785 }
786
787 if ( $key === 'content_score' && ! $this->readability_analysis->is_enabled() ) {
788 return true;
789 }
790
791 if ( $key === 'inclusive_language_score' && ! $this->inclusive_language_analysis->is_enabled() ) {
792 return true;
793 }
794
795 return false;
796 }
797
798 /**
799 * Enqueues all the needed JS and CSS.
800 *
801 * @todo [JRF => whomever] Create css/metabox-mp6.css file and add it to the below allowed colors array when done.
802 *
803 * @return void
804 */
805 public function enqueue() {
806 global $pagenow;
807
808 if ( $this->readability_analysis->is_enabled() ) {
809 $this->editor = new WPSEO_Metabox_Editor();
810 $this->editor->register_hooks();
811 }
812
813 $asset_manager = new WPSEO_Admin_Asset_Manager();
814
815 if ( self::is_post_overview( $pagenow ) ) {
816 return;
817 }
818
819 /* Filter 'wpseo_always_register_metaboxes_on_admin' documented in wpseo-main.php */
820 if ( ( self::is_post_edit( $pagenow ) === false && apply_filters( 'wpseo_always_register_metaboxes_on_admin', false ) === false ) || $this->display_metabox() === false ) {
821 return;
822 }
823
824 $post_id = get_queried_object_id();
825 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information.
826 if ( empty( $post_id ) && isset( $_GET['post'] ) && is_string( $_GET['post'] ) ) {
827 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information.
828 $post_id = sanitize_text_field( wp_unslash( $_GET['post'] ) );
829 }
830
831 if ( $post_id !== 0 ) {
832 // Enqueue files needed for upload functionality.
833 wp_enqueue_media( [ 'post' => $post_id ] );
834 }
835
836 $asset_manager->enqueue_style( 'metabox-css' );
837 if ( $this->readability_analysis->is_enabled() ) {
838 $asset_manager->enqueue_style( 'scoring' );
839 }
840 $asset_manager->enqueue_style( 'monorepo' );
841 $asset_manager->enqueue_style( 'ai-generator' );
842 $asset_manager->enqueue_style( 'ai-fix-assessments' );
843
844 $is_block_editor = WP_Screen::get()->is_block_editor();
845 $post_edit_handle = 'post-edit';
846 if ( ! $is_block_editor ) {
847 $post_edit_handle = 'post-edit-classic';
848 }
849 $asset_manager->enqueue_script( $post_edit_handle );
850 $asset_manager->enqueue_style( 'admin-css' );
851
852 /**
853 * Removes the emoji script as it is incompatible with both React and any
854 * contenteditable fields.
855 */
856 remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
857
858 $asset_manager->localize_script( $post_edit_handle, 'wpseoAdminL10n', WPSEO_Utils::get_admin_l10n() );
859
860 $plugins_script_data = [
861 'replaceVars' => [
862 'replace_vars' => $this->get_replace_vars(),
863 'hidden_replace_vars' => $this->get_hidden_replace_vars(),
864 'recommended_replace_vars' => $this->get_recommended_replace_vars(),
865 'scope' => $this->determine_scope(),
866 'has_taxonomies' => $this->current_post_type_has_taxonomies(),
867 ],
868 'shortcodes' => [
869 'wpseo_shortcode_tags' => $this->get_valid_shortcode_tags(),
870 'wpseo_filter_shortcodes_nonce' => wp_create_nonce( 'wpseo-filter-shortcodes' ),
871 ],
872 ];
873
874 $worker_script_data = [
875 'url' => YoastSEO()->helpers->asset->get_asset_url( 'yoast-seo-analysis-worker' ),
876 'dependencies' => YoastSEO()->helpers->asset->get_dependency_urls_by_handle( 'yoast-seo-analysis-worker' ),
877 'keywords_assessment_url' => YoastSEO()->helpers->asset->get_asset_url( 'yoast-seo-used-keywords-assessment' ),
878 'log_level' => WPSEO_Utils::get_analysis_worker_log_level(),
879 ];
880
881 $page_on_front = (int) get_option( 'page_on_front' );
882 $homepage_is_page = get_option( 'show_on_front' ) === 'page';
883 $is_front_page = $homepage_is_page && $page_on_front === (int) $post_id;
884
885 $script_data = [
886 'metabox' => $this->get_metabox_script_data(),
887 'isPost' => true,
888 'isBlockEditor' => $is_block_editor,
889 'postId' => $post_id,
890 'postStatus' => get_post_status( $post_id ),
891 'postType' => get_post_type( $post_id ),
892 'isPage' => get_post_type( $post_id ) === 'page',
893 'usedKeywordsNonce' => wp_create_nonce( 'wpseo-keyword-usage-and-post-types' ),
894 'analysis' => [
895 'plugins' => $plugins_script_data,
896 'worker' => $worker_script_data,
897 ],
898 'isFrontPage' => $is_front_page,
899 ];
900
901 /**
902 * The website information repository.
903 *
904 * @var Website_Information_Repository $repo
905 */
906 $repo = YoastSEO()->classes->get( Website_Information_Repository::class );
907 $site_information = $repo->get_post_site_information();
908 $site_information->set_permalink( $this->get_permalink() );
909 $script_data = array_merge_recursive( $site_information->get_legacy_site_information(), $script_data );
910
911 if ( ! $is_block_editor && post_type_supports( get_post_type(), 'thumbnail' ) ) {
912 $asset_manager->enqueue_style( 'featured-image' );
913 }
914
915 $asset_manager->localize_script( $post_edit_handle, 'wpseoScriptData', $script_data );
916 }
917
918 /**
919 * Returns post in metabox context.
920 *
921 * @return WP_Post|array<string|int|bool>
922 */
923 protected function get_metabox_post() {
924 if ( $this->post !== null ) {
925 return $this->post;
926 }
927
928 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information.
929 if ( isset( $_GET['post'] ) && is_string( $_GET['post'] ) ) {
930 // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Reason: We are not processing form information, Sanitization happens in the validate_int function.
931 $post_id = (int) WPSEO_Utils::validate_int( wp_unslash( $_GET['post'] ) );
932
933 $this->post = get_post( $post_id );
934
935 return $this->post;
936 }
937
938 if ( isset( $GLOBALS['post'] ) ) {
939 $this->post = $GLOBALS['post'];
940
941 return $this->post;
942 }
943
944 return [];
945 }
946
947 /**
948 * Returns an array with shortcode tags for all registered shortcodes.
949 *
950 * @return string[]
951 */
952 private function get_valid_shortcode_tags() {
953 $shortcode_tags = [];
954
955 foreach ( $GLOBALS['shortcode_tags'] as $tag => $description ) {
956 $shortcode_tags[] = $tag;
957 }
958
959 return $shortcode_tags;
960 }
961
962 /**
963 * Prepares the replace vars for localization.
964 *
965 * @return string[] Replace vars.
966 */
967 private function get_replace_vars() {
968 $cached_replacement_vars = [];
969
970 $vars_to_cache = [
971 'date',
972 'id',
973 'sitename',
974 'sitedesc',
975 'sep',
976 'page',
977 'currentdate',
978 'currentyear',
979 'currentmonth',
980 'currentday',
981 'post_year',
982 'post_month',
983 'post_day',
984 'name',
985 'author_first_name',
986 'author_last_name',
987 'permalink',
988 'post_content',
989 'category_title',
990 'tag',
991 'category',
992 ];
993
994 foreach ( $vars_to_cache as $var ) {
995 $cached_replacement_vars[ $var ] = wpseo_replace_vars( '%%' . $var . '%%', $this->get_metabox_post() );
996 }
997
998 // Merge custom replace variables with the WordPress ones.
999 return array_merge( $cached_replacement_vars, $this->get_custom_replace_vars( $this->get_metabox_post() ) );
1000 }
1001
1002 /**
1003 * Returns the list of replace vars that should be hidden inside the editor.
1004 *
1005 * @return string[] The hidden replace vars.
1006 */
1007 protected function get_hidden_replace_vars() {
1008 return ( new WPSEO_Replace_Vars() )->get_hidden_replace_vars();
1009 }
1010
1011 /**
1012 * Prepares the recommended replace vars for localization.
1013 *
1014 * @return array<string[]> Recommended replacement variables.
1015 */
1016 private function get_recommended_replace_vars() {
1017 $recommended_replace_vars = new WPSEO_Admin_Recommended_Replace_Vars();
1018
1019 // What is recommended depends on the current context.
1020 $post_type = $recommended_replace_vars->determine_for_post( $this->get_metabox_post() );
1021
1022 return $recommended_replace_vars->get_recommended_replacevars_for( $post_type );
1023 }
1024
1025 /**
1026 * Gets the custom replace variables for custom taxonomies and fields.
1027 *
1028 * @param WP_Post $post The post to check for custom taxonomies and fields.
1029 *
1030 * @return array<string[]> Array containing all the replacement variables.
1031 */
1032 private function get_custom_replace_vars( $post ) {
1033 return [
1034 'custom_fields' => $this->get_custom_fields_replace_vars( $post ),
1035 'custom_taxonomies' => $this->get_custom_taxonomies_replace_vars( $post ),
1036 ];
1037 }
1038
1039 /**
1040 * Gets the custom replace variables for custom taxonomies.
1041 *
1042 * @param WP_Post $post The post to check for custom taxonomies.
1043 *
1044 * @return array<string[]> Array containing all the replacement variables.
1045 */
1046 private function get_custom_taxonomies_replace_vars( $post ) {
1047 $taxonomies = get_object_taxonomies( $post, 'objects' );
1048 $custom_replace_vars = [];
1049
1050 foreach ( $taxonomies as $taxonomy_name => $taxonomy ) {
1051
1052 if ( is_string( $taxonomy ) ) { // If attachment, see https://core.trac.wordpress.org/ticket/37368 .
1053 $taxonomy_name = $taxonomy;
1054 $taxonomy = get_taxonomy( $taxonomy_name );
1055 }
1056
1057 if ( $taxonomy->_builtin && $taxonomy->public ) {
1058 continue;
1059 }
1060
1061 $custom_replace_vars[ $taxonomy_name ] = [
1062 'name' => $taxonomy->name,
1063 'description' => $taxonomy->description,
1064 ];
1065 }
1066
1067 return $custom_replace_vars;
1068 }
1069
1070 /**
1071 * Gets the custom replace variables for custom fields.
1072 *
1073 * @param WP_Post $post The post to check for custom fields.
1074 *
1075 * @return array<string[]> Array containing all the replacement variables.
1076 */
1077 private function get_custom_fields_replace_vars( $post ) {
1078 $custom_replace_vars = [];
1079
1080 // If no post object is passed, return the empty custom_replace_vars array.
1081 if ( ! is_object( $post ) ) {
1082 return $custom_replace_vars;
1083 }
1084
1085 $custom_fields = get_post_custom( $post->ID );
1086
1087 // If $custom_fields is an empty string or generally not an array, return early.
1088 if ( ! is_array( $custom_fields ) ) {
1089 return $custom_replace_vars;
1090 }
1091
1092 $meta = YoastSEO()->meta->for_post( $post->ID );
1093
1094 if ( ! $meta ) {
1095 return $custom_replace_vars;
1096 }
1097
1098 // Simply concatenate all fields containing replace vars so we can handle them all with a single regex find.
1099 $replace_vars_fields = implode(
1100 ' ',
1101 [
1102 $meta->presentation->title,
1103 $meta->presentation->meta_description,
1104 ],
1105 );
1106
1107 preg_match_all( '/%%cf_([A-Za-z0-9_]+)%%/', $replace_vars_fields, $matches );
1108 $fields_to_include = $matches[1];
1109 foreach ( $custom_fields as $custom_field_name => $custom_field ) {
1110 // Skip private custom fields.
1111 if ( substr( $custom_field_name, 0, 1 ) === '_' ) {
1112 continue;
1113 }
1114
1115 // Skip custom fields that are not used, new ones will be fetched dynamically.
1116 if ( ! in_array( $custom_field_name, $fields_to_include, true ) ) {
1117 continue;
1118 }
1119
1120 // Skip custom field values that are serialized.
1121 if ( is_serialized( $custom_field[0] ) ) {
1122 continue;
1123 }
1124
1125 $custom_replace_vars[ $custom_field_name ] = $custom_field[0];
1126 }
1127
1128 return $custom_replace_vars;
1129 }
1130
1131 /**
1132 * Checks if the page is the post overview page.
1133 *
1134 * @param string $page The page to check for the post overview page.
1135 *
1136 * @return bool Whether or not the given page is the post overview page.
1137 */
1138 public static function is_post_overview( $page ) {
1139 return $page === 'edit.php';
1140 }
1141
1142 /**
1143 * Checks if the page is the post edit page.
1144 *
1145 * @param string $page The page to check for the post edit page.
1146 *
1147 * @return bool Whether or not the given page is the post edit page.
1148 */
1149 public static function is_post_edit( $page ) {
1150 return $page === 'post.php'
1151 || $page === 'post-new.php';
1152 }
1153
1154 /**
1155 * Retrieves the product title.
1156 *
1157 * @return string The product title.
1158 */
1159 protected function get_product_title() {
1160 return YoastSEO()->helpers->product->get_product_name();
1161 }
1162
1163 /**
1164 * Gets the permalink.
1165 *
1166 * @return string
1167 */
1168 protected function get_permalink() {
1169 $permalink = '';
1170
1171 if ( is_object( $this->get_metabox_post() ) ) {
1172 $permalink = get_sample_permalink( $this->get_metabox_post()->ID );
1173 $permalink = $permalink[0];
1174 }
1175
1176 return $permalink;
1177 }
1178 }
1179