PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 18.2
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v18.2
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
wordpress-seo / admin / metabox / class-metabox.php

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

1,147 lines 37.6 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\Conditionals\Admin\Estimated_Reading_Time_Conditional;
9 use Yoast\WP\SEO\Conditionals\Admin\Post_Conditional;
10 use Yoast\WP\SEO\Helpers\Input_Helper;
11 use Yoast\WP\SEO\Presenters\Admin\Alert_Presenter;
12 use Yoast\WP\SEO\Presenters\Admin\Meta_Fields_Presenter;
13
14 /**
15 * This class generates the metabox on the edit post / page as well as contains all page analysis functionality.
16 */
17 class WPSEO_Metabox extends WPSEO_Meta {
18
19 /**
20 * Whether or not the social tab is enabled.
21 *
22 * @var bool
23 */
24 private $social_is_enabled;
25
26 /**
27 * Helper to determine whether or not the SEO analysis is enabled.
28 *
29 * @var WPSEO_Metabox_Analysis_SEO
30 */
31 protected $seo_analysis;
32
33 /**
34 * Helper to determine whether or not the readability analysis is enabled.
35 *
36 * @var WPSEO_Metabox_Analysis_Readability
37 */
38 protected $readability_analysis;
39
40 /**
41 * The metabox editor object.
42 *
43 * @var WPSEO_Metabox_Editor
44 */
45 protected $editor;
46
47 /**
48 * The Metabox post.
49 *
50 * @var WP_Post
51 */
52 protected $post = null;
53
54 /**
55 * Whether or not the advanced metadata is enabled.
56 *
57 * @var bool
58 */
59 protected $is_advanced_metadata_enabled;
60
61 /**
62 * Represents the estimated_reading_time_conditional.
63 *
64 * @var Estimated_Reading_Time_Conditional
65 */
66 protected $estimated_reading_time_conditional;
67
68 /**
69 * Class constructor.
70 */
71 public function __construct() {
72 if ( $this->is_internet_explorer() ) {
73 add_action( 'add_meta_boxes', [ $this, 'internet_explorer_metabox' ] );
74 return;
75 }
76
77 add_action( 'add_meta_boxes', [ $this, 'add_meta_box' ] );
78 add_action( 'admin_enqueue_scripts', [ $this, 'enqueue' ] );
79 add_action( 'wp_insert_post', [ $this, 'save_postdata' ] );
80 add_action( 'edit_attachment', [ $this, 'save_postdata' ] );
81 add_action( 'add_attachment', [ $this, 'save_postdata' ] );
82 add_action( 'admin_init', [ $this, 'translate_meta_boxes' ] );
83
84 $this->editor = new WPSEO_Metabox_Editor();
85 $this->editor->register_hooks();
86
87 $this->social_is_enabled = WPSEO_Options::get( 'opengraph', false ) || WPSEO_Options::get( 'twitter', false );
88 $this->is_advanced_metadata_enabled = WPSEO_Capability_Utils::current_user_can( 'wpseo_edit_advanced_metadata' ) || WPSEO_Options::get( 'disableadvanced_meta' ) === false;
89
90 $this->estimated_reading_time_conditional = new Estimated_Reading_Time_Conditional(
91 new Post_Conditional(),
92 new Input_Helper()
93 );
94
95 $this->seo_analysis = new WPSEO_Metabox_Analysis_SEO();
96 $this->readability_analysis = new WPSEO_Metabox_Analysis_Readability();
97 }
98
99 /**
100 * Checks whether the request comes from an IE 11 browser.
101 *
102 * @return bool Whether the request comes from an IE 11 browser.
103 */
104 public static function is_internet_explorer() {
105 if ( empty( $_SERVER['HTTP_USER_AGENT'] ) ) {
106 return false;
107 }
108
109 $user_agent = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) );
110
111 if ( stripos( $user_agent, 'Trident/7.0' ) === false ) {
112 return false;
113 }
114
115 return true;
116 }
117
118 /**
119 * Adds an alternative metabox for internet explorer users.
120 */
121 public function internet_explorer_metabox() {
122 $post_types = WPSEO_Post_Type::get_accessible_post_types();
123 $post_types = array_filter( $post_types, [ $this, 'display_metabox' ] );
124
125 if ( ! is_array( $post_types ) || $post_types === [] ) {
126 return;
127 }
128
129 $product_title = $this->get_product_title();
130
131 foreach ( $post_types as $post_type ) {
132 add_filter( "postbox_classes_{$post_type}_wpseo_meta", [ $this, 'wpseo_metabox_class' ] );
133
134 add_meta_box(
135 'wpseo_meta',
136 $product_title,
137 [ $this, 'render_internet_explorer_notice' ],
138 $post_type,
139 'normal',
140 apply_filters( 'wpseo_metabox_prio', 'high' ),
141 [ '__block_editor_compatible_meta_box' => true ]
142 );
143 }
144 }
145
146 /**
147 * Renders the content for the internet explorer metabox.
148 *
149 * @return void
150 */
151 public function render_internet_explorer_notice() {
152 $content = sprintf(
153 /* 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. */
154 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' ),
155 '<a href="https://www.mozilla.org/firefox/new/">',
156 '<a href="https://www.google.com/chrome/">',
157 '<a href="https://www.microsoft.com/windows/microsoft-edge">',
158 '</a>'
159 );
160
161 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output escaped above.
162 echo new Alert_Presenter( $content );
163 }
164
165 /**
166 * Translates text strings for use in the meta box.
167 *
168 * IMPORTANT: if you want to add a new string (option) somewhere, make sure you add that array key to
169 * the main meta box definition array in the class WPSEO_Meta() as well!!!!
170 */
171 public static function translate_meta_boxes() {
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( 'wpseo_tab_translate' );
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 array $classes An array of postbox CSS classes.
263 *
264 * @return array 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
276 */
277 public function get_metabox_script_data() {
278 $permalink = '';
279
280 if ( is_object( $this->get_metabox_post() ) ) {
281 $permalink = get_sample_permalink( $this->get_metabox_post()->ID );
282 $permalink = $permalink[0];
283 }
284
285 $post_formatter = new WPSEO_Metabox_Formatter(
286 new WPSEO_Post_Metabox_Formatter( $this->get_metabox_post(), [], $permalink )
287 );
288
289 $values = $post_formatter->get_values();
290
291 /** This filter is documented in admin/filters/class-cornerstone-filter.php. */
292 $post_types = apply_filters( 'wpseo_cornerstone_post_types', WPSEO_Post_Type::get_accessible_post_types() );
293 if ( $values['cornerstoneActive'] && ! in_array( $this->get_metabox_post()->post_type, $post_types, true ) ) {
294 $values['cornerstoneActive'] = false;
295 }
296
297 if ( $values['semrushIntegrationActive'] && $this->post->post_type === 'attachment' ) {
298 $values['semrushIntegrationActive'] = 0;
299 }
300
301 if ( $values['wincherIntegrationActive'] && $this->post->post_type === 'attachment' ) {
302 $values['wincherIntegrationActive'] = 0;
303 }
304
305 return $values;
306 }
307
308 /**
309 * Determines whether or not the current post type has registered taxonomies.
310 *
311 * @return bool Whether the current post type has taxonomies.
312 */
313 private function current_post_type_has_taxonomies() {
314 $post_taxonomies = get_object_taxonomies( get_post_type() );
315
316 return ! empty( $post_taxonomies );
317 }
318
319 /**
320 * Determines the scope based on the post type.
321 * This can be used by the replacevar plugin to determine if a replacement needs to be executed.
322 *
323 * @return string String describing the current scope.
324 */
325 private function determine_scope() {
326 if ( $this->get_metabox_post()->post_type === 'page' ) {
327 return 'page';
328 }
329
330 return 'post';
331 }
332
333 /**
334 * Outputs the meta box.
335 */
336 public function meta_box() {
337 $this->render_hidden_fields();
338 $this->render_tabs();
339 }
340
341 /**
342 * Renders the metabox hidden fields.
343 *
344 * @return void
345 */
346 protected function render_hidden_fields() {
347 wp_nonce_field( 'yoast_free_metabox', 'yoast_free_metabox_nonce' );
348
349 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output escaped in class.
350 echo new Meta_Fields_Presenter( $this->get_metabox_post(), 'general' );
351
352 if ( $this->is_advanced_metadata_enabled ) {
353 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output escaped in class.
354 echo new Meta_Fields_Presenter( $this->get_metabox_post(), 'advanced' );
355 }
356
357 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output escaped in class.
358 echo new Meta_Fields_Presenter( $this->get_metabox_post(), 'schema', $this->get_metabox_post()->post_type );
359
360 if ( $this->social_is_enabled ) {
361 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output escaped in class.
362 echo new Meta_Fields_Presenter( $this->get_metabox_post(), 'social' );
363 }
364
365 /**
366 * Filter: 'wpseo_content_meta_section_content' - Allow filtering the metabox content before outputting.
367 *
368 * @api string $post_content The metabox content string.
369 */
370 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output should be escaped in the filter.
371 echo apply_filters( 'wpseo_content_meta_section_content', '' );
372 }
373
374 /**
375 * Renders the metabox tabs.
376 *
377 * @return void
378 */
379 protected function render_tabs() {
380 echo '<div class="wpseo-metabox-content">';
381 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Reason: $this->get_product_title() returns a hard-coded string.
382 printf( '<div class="wpseo-metabox-menu"><ul role="tablist" class="yoast-aria-tabs" aria-label="%s">', $this->get_product_title() );
383
384 $tabs = $this->get_tabs();
385
386 foreach ( $tabs as $tab ) {
387 if ( $tab->name === 'premium' ) {
388 continue;
389 }
390
391 $tab->display_link();
392 }
393
394 echo '</ul></div>';
395
396 foreach ( $tabs as $tab ) {
397 $tab->display_content();
398 }
399
400 echo '</div>';
401 }
402
403 /**
404 * Returns the relevant metabox tabs for the current view.
405 *
406 * @return WPSEO_Metabox_Section[]
407 */
408 private function get_tabs() {
409 $tabs = [];
410
411 $label = __( 'SEO', 'wordpress-seo' );
412 if ( $this->seo_analysis->is_enabled() ) {
413 $label = '<span class="wpseo-score-icon-container" id="wpseo-seo-score-icon"></span>' . $label;
414 }
415 $tabs[] = new WPSEO_Metabox_Section_React( 'content', $label );
416
417 if ( $this->readability_analysis->is_enabled() ) {
418 $tabs[] = new WPSEO_Metabox_Section_Readability();
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 * @todo [JRF] Check if $class is added appropriately everywhere.
499 *
500 * @param array $meta_field_def Contains the vars based on which output is generated.
501 * @param string $key Internal key (without prefix).
502 *
503 * @return string
504 */
505 public function do_meta_box( $meta_field_def, $key = '' ) {
506 $content = '';
507 $esc_form_key = esc_attr( WPSEO_Meta::$form_prefix . $key );
508 $meta_value = WPSEO_Meta::get_value( $key, $this->get_metabox_post()->ID );
509
510 $class = '';
511 if ( isset( $meta_field_def['class'] ) && $meta_field_def['class'] !== '' ) {
512 $class = ' ' . $meta_field_def['class'];
513 }
514
515 $placeholder = '';
516 if ( isset( $meta_field_def['placeholder'] ) && $meta_field_def['placeholder'] !== '' ) {
517 $placeholder = $meta_field_def['placeholder'];
518 }
519
520 $aria_describedby = '';
521 $description = '';
522 if ( isset( $meta_field_def['description'] ) ) {
523 $aria_describedby = ' aria-describedby="' . $esc_form_key . '-desc"';
524 $description = '<p id="' . $esc_form_key . '-desc" class="yoast-metabox__description">' . $meta_field_def['description'] . '</p>';
525 }
526
527 // Add a hide_on_pages option that returns nothing when the field is rendered on a page.
528 if ( isset( $meta_field_def['hide_on_pages'] ) && $meta_field_def['hide_on_pages'] && get_post_type() === 'page' ) {
529 return '';
530 }
531
532 switch ( $meta_field_def['type'] ) {
533 case 'text':
534 $ac = '';
535 if ( isset( $meta_field_def['autocomplete'] ) && $meta_field_def['autocomplete'] === false ) {
536 $ac = 'autocomplete="off" ';
537 }
538 if ( $placeholder !== '' ) {
539 $placeholder = ' placeholder="' . esc_attr( $placeholder ) . '"';
540 }
541 $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 . '/>';
542 break;
543
544 case 'url':
545 if ( $placeholder !== '' ) {
546 $placeholder = ' placeholder="' . esc_attr( $placeholder ) . '"';
547 }
548 $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 . '/>';
549 break;
550
551 case 'textarea':
552 $rows = 3;
553 if ( isset( $meta_field_def['rows'] ) && $meta_field_def['rows'] > 0 ) {
554 $rows = $meta_field_def['rows'];
555 }
556 $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>';
557 break;
558
559 case 'hidden':
560 $default = '';
561 if ( isset( $meta_field_def['default'] ) ) {
562 $default = sprintf( ' data-default="%s"', esc_attr( $meta_field_def['default'] ) );
563 }
564 $content .= '<input type="hidden" id="' . $esc_form_key . '" name="' . $esc_form_key . '" value="' . esc_attr( $meta_value ) . '"' . $default . '/>' . "\n";
565 break;
566 case 'select':
567 if ( isset( $meta_field_def['options'] ) && is_array( $meta_field_def['options'] ) && $meta_field_def['options'] !== [] ) {
568 $content .= '<select name="' . $esc_form_key . '" id="' . $esc_form_key . '" class="yoast' . $class . '">';
569 foreach ( $meta_field_def['options'] as $val => $option ) {
570 $selected = selected( $meta_value, $val, false );
571 $content .= '<option ' . $selected . ' value="' . esc_attr( $val ) . '">' . esc_html( $option ) . '</option>';
572 }
573 unset( $val, $option, $selected );
574 $content .= '</select>';
575 }
576 break;
577
578 case 'multiselect':
579 if ( isset( $meta_field_def['options'] ) && is_array( $meta_field_def['options'] ) && $meta_field_def['options'] !== [] ) {
580
581 // Set $meta_value as $selected_arr.
582 $selected_arr = $meta_value;
583
584 // If the multiselect field is 'meta-robots-adv' we should explode on ,.
585 if ( $key === 'meta-robots-adv' ) {
586 $selected_arr = explode( ',', $meta_value );
587 }
588
589 if ( ! is_array( $selected_arr ) ) {
590 $selected_arr = (array) $selected_arr;
591 }
592
593 $options_count = count( $meta_field_def['options'] );
594
595 // This select now uses Select2.
596 $content .= '<select multiple="multiple" size="' . esc_attr( $options_count ) . '" name="' . $esc_form_key . '[]" id="' . $esc_form_key . '" class="yoast' . $class . '"' . $aria_describedby . '>';
597 foreach ( $meta_field_def['options'] as $val => $option ) {
598 $selected = '';
599 if ( in_array( $val, $selected_arr, true ) ) {
600 $selected = ' selected="selected"';
601 }
602 $content .= '<option ' . $selected . ' value="' . esc_attr( $val ) . '">' . esc_html( $option ) . '</option>';
603 }
604 $content .= '</select>';
605 unset( $val, $option, $selected, $selected_arr, $options_count );
606 }
607 break;
608
609 case 'checkbox':
610 $checked = checked( $meta_value, 'on', false );
611 $expl = ( isset( $meta_field_def['expl'] ) ) ? esc_html( $meta_field_def['expl'] ) : '';
612 $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>';
613 unset( $checked, $expl );
614 break;
615
616 case 'radio':
617 if ( isset( $meta_field_def['options'] ) && is_array( $meta_field_def['options'] ) && $meta_field_def['options'] !== [] ) {
618 foreach ( $meta_field_def['options'] as $val => $option ) {
619 $checked = checked( $meta_value, $val, false );
620 $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> ';
621 }
622 unset( $val, $option, $checked );
623 }
624 break;
625
626 case 'upload':
627 $content .= '<input' .
628 ' id="' . $esc_form_key . '"' .
629 ' type="text"' .
630 ' size="36"' .
631 ' class="' . $class . '"' .
632 ' name="' . $esc_form_key . '"' .
633 ' value="' . esc_attr( $meta_value ) . '"' . $aria_describedby .
634 ' readonly="readonly"' .
635 ' /> ';
636 $content .= '<input' .
637 ' id="' . esc_attr( $esc_form_key ) . '_button"' .
638 ' class="wpseo_image_upload_button button"' .
639 ' data-target="' . esc_attr( $esc_form_key ) . '"' .
640 ' data-target-id="' . esc_attr( $esc_form_key ) . '-id"' .
641 ' type="button"' .
642 ' value="' . esc_attr__( 'Upload Image', 'wordpress-seo' ) . '"' .
643 ' /> ';
644 $content .= '<input' .
645 ' class="wpseo_image_remove_button button"' .
646 ' type="button"' .
647 ' value="' . esc_attr__( 'Clear Image', 'wordpress-seo' ) . '"' .
648 ' />';
649 break;
650 }
651
652 $html = '';
653 if ( $content === '' ) {
654 $content = apply_filters( 'wpseo_do_meta_box_field_' . $key, $content, $meta_value, $esc_form_key, $meta_field_def, $key );
655 }
656
657 if ( $content !== '' ) {
658
659 $title = esc_html( $meta_field_def['title'] );
660
661 // By default, use the field title as a label element.
662 $label = '<label for="' . $esc_form_key . '">' . $title . '</label>';
663
664 // Set the inline help and help panel, if any.
665 $help_button = '';
666 $help_panel = '';
667 if ( isset( $meta_field_def['help'] ) && $meta_field_def['help'] !== '' ) {
668 $help = new WPSEO_Admin_Help_Panel( $key, $meta_field_def['help-button'], $meta_field_def['help'] );
669 $help_button = $help->get_button_html();
670 $help_panel = $help->get_panel_html();
671 }
672
673 // If it's a set of radio buttons, output proper fieldset and legend.
674 if ( $meta_field_def['type'] === 'radio' ) {
675 return '<fieldset><legend>' . $title . '</legend>' . $help_button . $help_panel . $content . $description . '</fieldset>';
676 }
677
678 // If it's a single checkbox, ignore the title.
679 if ( $meta_field_def['type'] === 'checkbox' ) {
680 $label = '';
681 }
682
683 // Other meta box content or form fields.
684 if ( $meta_field_def['type'] === 'hidden' ) {
685 $html = $content;
686 }
687 else {
688 $html = $label . $description . $help_button . $help_panel . $content;
689 }
690 }
691
692 return $html;
693 }
694
695 /**
696 * Saves the WP SEO metadata for posts.
697 *
698 * {@internal $_POST parameters are validated via sanitize_post_meta().}}
699 *
700 * @param int $post_id Post ID.
701 *
702 * @return bool|void Boolean false if invalid save post request.
703 */
704 public function save_postdata( $post_id ) {
705 // Bail if this is a multisite installation and the site has been switched.
706 if ( is_multisite() && ms_is_switched() ) {
707 return false;
708 }
709
710 if ( $post_id === null ) {
711 return false;
712 }
713
714 if ( ! isset( $_POST['yoast_free_metabox_nonce'] ) || ! wp_verify_nonce( $_POST['yoast_free_metabox_nonce'], 'yoast_free_metabox' ) ) {
715 return false;
716 }
717
718 if ( wp_is_post_revision( $post_id ) ) {
719 $post_id = wp_is_post_revision( $post_id );
720 }
721
722 /**
723 * Determine we're not accidentally updating a different post.
724 * We can't use filter_input here as the ID isn't available at this point, other than in the $_POST data.
725 */
726 if ( ! isset( $_POST['ID'] ) || $post_id !== (int) $_POST['ID'] ) {
727 return false;
728 }
729
730 clean_post_cache( $post_id );
731 $post = get_post( $post_id );
732
733 if ( ! is_object( $post ) ) {
734 // Non-existent post.
735 return false;
736 }
737
738 do_action( 'wpseo_save_compare_data', $post );
739
740 $social_fields = [];
741 if ( $this->social_is_enabled ) {
742 $social_fields = WPSEO_Meta::get_meta_field_defs( 'social' );
743 }
744
745 $meta_boxes = apply_filters( 'wpseo_save_metaboxes', [] );
746 $meta_boxes = array_merge(
747 $meta_boxes,
748 WPSEO_Meta::get_meta_field_defs( 'general', $post->post_type ),
749 WPSEO_Meta::get_meta_field_defs( 'advanced' ),
750 $social_fields,
751 WPSEO_Meta::get_meta_field_defs( 'schema', $post->post_type )
752 );
753
754 foreach ( $meta_boxes as $key => $meta_box ) {
755
756 // If analysis is disabled remove that analysis score value from the DB.
757 if ( $this->is_meta_value_disabled( $key ) ) {
758 WPSEO_Meta::delete( $key, $post_id );
759 continue;
760 }
761
762 $data = null;
763 $field_name = WPSEO_Meta::$form_prefix . $key;
764
765 if ( $meta_box['type'] === 'checkbox' ) {
766 $data = isset( $_POST[ $field_name ] ) ? 'on' : 'off';
767 }
768 else {
769 if ( isset( $_POST[ $field_name ] ) ) {
770 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- We're preparing to do just that.
771 $data = wp_unslash( $_POST[ $field_name ] );
772
773 // For multi-select.
774 if ( is_array( $data ) ) {
775 $data = array_map( [ 'WPSEO_Utils', 'sanitize_text_field' ], $data );
776 }
777
778 if ( is_string( $data ) ) {
779 $data = ( $key !== 'canonical' ) ? WPSEO_Utils::sanitize_text_field( $data ) : WPSEO_Utils::sanitize_url( $data );
780 }
781 }
782
783 // Reset options when no entry is present with multiselect - only applies to `meta-robots-adv` currently.
784 if ( ! isset( $_POST[ $field_name ] ) && ( $meta_box['type'] === 'multiselect' ) ) {
785 $data = [];
786 }
787 }
788
789 if ( $data !== null ) {
790 WPSEO_Meta::set_value( $key, $data, $post_id );
791 }
792 }
793
794 do_action( 'wpseo_saved_postdata' );
795 }
796
797 /**
798 * Determines if the given meta value key is disabled.
799 *
800 * @param string $key The key of the meta value.
801 *
802 * @return bool Whether the given meta value key is disabled.
803 */
804 public function is_meta_value_disabled( $key ) {
805 if ( $key === 'linkdex' && ! $this->seo_analysis->is_enabled() ) {
806 return true;
807 }
808
809 if ( $key === 'content_score' && ! $this->readability_analysis->is_enabled() ) {
810 return true;
811 }
812
813 return false;
814 }
815
816 /**
817 * Enqueues all the needed JS and CSS.
818 *
819 * @todo [JRF => whomever] Create css/metabox-mp6.css file and add it to the below allowed colors array when done.
820 */
821 public function enqueue() {
822 global $pagenow;
823
824 $asset_manager = new WPSEO_Admin_Asset_Manager();
825
826 $is_editor = self::is_post_overview( $pagenow ) || self::is_post_edit( $pagenow );
827
828 if ( self::is_post_overview( $pagenow ) ) {
829 $asset_manager->enqueue_style( 'edit-page' );
830 $asset_manager->enqueue_script( 'edit-page' );
831
832 return;
833 }
834
835 /* Filter 'wpseo_always_register_metaboxes_on_admin' documented in wpseo-main.php */
836 if ( ( $is_editor === false && apply_filters( 'wpseo_always_register_metaboxes_on_admin', false ) === false ) || $this->display_metabox() === false ) {
837 return;
838 }
839
840 $post_id = get_queried_object_id();
841 if ( empty( $post_id ) && isset( $_GET['post'] ) ) {
842 $post_id = sanitize_text_field( filter_input( INPUT_GET, 'post' ) );
843 }
844
845 if ( $post_id !== 0 ) {
846 // Enqueue files needed for upload functionality.
847 wp_enqueue_media( [ 'post' => $post_id ] );
848 }
849
850 $asset_manager->enqueue_style( 'metabox-css' );
851 $asset_manager->enqueue_style( 'scoring' );
852 $asset_manager->enqueue_style( 'select2' );
853 $asset_manager->enqueue_style( 'monorepo' );
854
855 $is_block_editor = WP_Screen::get()->is_block_editor();
856 $post_edit_handle = 'post-edit';
857 if ( ! $is_block_editor ) {
858 $post_edit_handle = 'post-edit-classic';
859 }
860 $asset_manager->enqueue_script( $post_edit_handle );
861 $asset_manager->enqueue_style( 'admin-css' );
862
863 /**
864 * Removes the emoji script as it is incompatible with both React and any
865 * contenteditable fields.
866 */
867 remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
868
869 $asset_manager->localize_script( $post_edit_handle, 'wpseoAdminL10n', WPSEO_Utils::get_admin_l10n() );
870
871 $plugins_script_data = [
872 'replaceVars' => [
873 'no_parent_text' => __( '(no parent)', 'wordpress-seo' ),
874 'replace_vars' => $this->get_replace_vars(),
875 'hidden_replace_vars' => $this->get_hidden_replace_vars(),
876 'recommended_replace_vars' => $this->get_recommended_replace_vars(),
877 'scope' => $this->determine_scope(),
878 'has_taxonomies' => $this->current_post_type_has_taxonomies(),
879 ],
880 'shortcodes' => [
881 'wpseo_filter_shortcodes_nonce' => wp_create_nonce( 'wpseo-filter-shortcodes' ),
882 'wpseo_shortcode_tags' => $this->get_valid_shortcode_tags(),
883 ],
884 ];
885
886 $worker_script_data = [
887 'url' => YoastSEO()->helpers->asset->get_asset_url( 'yoast-seo-analysis-worker' ),
888 'dependencies' => YoastSEO()->helpers->asset->get_dependency_urls_by_handle( 'yoast-seo-analysis-worker' ),
889 'keywords_assessment_url' => YoastSEO()->helpers->asset->get_asset_url( 'yoast-seo-used-keywords-assessment' ),
890 'log_level' => WPSEO_Utils::get_analysis_worker_log_level(),
891 ];
892
893 $alert_dismissal_action = YoastSEO()->classes->get( \Yoast\WP\SEO\Actions\Alert_Dismissal_Action::class );
894 $dismissed_alerts = $alert_dismissal_action->all_dismissed();
895
896 $script_data = [
897 // @todo replace this translation with JavaScript translations.
898 'media' => [ 'choose_image' => __( 'Use Image', 'wordpress-seo' ) ],
899 'metabox' => $this->get_metabox_script_data(),
900 'userLanguageCode' => WPSEO_Language_Utils::get_language( \get_user_locale() ),
901 'isPost' => true,
902 'isBlockEditor' => $is_block_editor,
903 'postStatus' => get_post_status( $post_id ),
904 'analysis' => [
905 'plugins' => $plugins_script_data,
906 'worker' => $worker_script_data,
907 'estimatedReadingTimeEnabled' => $this->estimated_reading_time_conditional->is_met(),
908 ],
909 'dismissedAlerts' => $dismissed_alerts,
910 ];
911
912 if ( post_type_supports( get_post_type(), 'thumbnail' ) ) {
913 $asset_manager->enqueue_style( 'featured-image' );
914
915 // @todo replace this translation with JavaScript translations.
916 $script_data['featuredImage'] = [
917 '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' ),
918 ];
919 }
920
921 $asset_manager->localize_script( $post_edit_handle, 'wpseoScriptData', $script_data );
922 $asset_manager->enqueue_user_language_script();
923 }
924
925 /**
926 * Returns post in metabox context.
927 *
928 * @return WP_Post|array
929 */
930 protected function get_metabox_post() {
931 if ( $this->post !== null ) {
932 return $this->post;
933 }
934
935 $post = filter_input( INPUT_GET, 'post' );
936 if ( ! empty( $post ) ) {
937 $post_id = (int) WPSEO_Utils::validate_int( $post );
938
939 $this->post = get_post( $post_id );
940
941 return $this->post;
942 }
943
944 if ( isset( $GLOBALS['post'] ) ) {
945 $this->post = $GLOBALS['post'];
946
947 return $this->post;
948 }
949
950 return [];
951 }
952
953 /**
954 * Returns an array with shortcode tags for all registered shortcodes.
955 *
956 * @return array
957 */
958 private function get_valid_shortcode_tags() {
959 $shortcode_tags = [];
960
961 foreach ( $GLOBALS['shortcode_tags'] as $tag => $description ) {
962 $shortcode_tags[] = $tag;
963 }
964
965 return $shortcode_tags;
966 }
967
968 /**
969 * Prepares the replace vars for localization.
970 *
971 * @return array Replace vars.
972 */
973 private function get_replace_vars() {
974 $cached_replacement_vars = [];
975
976 $vars_to_cache = [
977 'date',
978 'id',
979 'sitename',
980 'sitedesc',
981 'sep',
982 'page',
983 'currentdate',
984 'currentyear',
985 'currentmonth',
986 'currentday',
987 'post_year',
988 'post_month',
989 'post_day',
990 'name',
991 'author_first_name',
992 'author_last_name',
993 'permalink',
994 'post_content',
995 'category_title',
996 'tag',
997 'category',
998 ];
999
1000 foreach ( $vars_to_cache as $var ) {
1001 $cached_replacement_vars[ $var ] = wpseo_replace_vars( '%%' . $var . '%%', $this->get_metabox_post() );
1002 }
1003
1004 // Merge custom replace variables with the WordPress ones.
1005 return array_merge( $cached_replacement_vars, $this->get_custom_replace_vars( $this->get_metabox_post() ) );
1006 }
1007
1008 /**
1009 * Returns the list of replace vars that should be hidden inside the editor.
1010 *
1011 * @return string[] The hidden replace vars.
1012 */
1013 protected function get_hidden_replace_vars() {
1014 return ( new WPSEO_Replace_Vars() )->get_hidden_replace_vars();
1015 }
1016
1017 /**
1018 * Prepares the recommended replace vars for localization.
1019 *
1020 * @return array Recommended replacement variables.
1021 */
1022 private function get_recommended_replace_vars() {
1023 $recommended_replace_vars = new WPSEO_Admin_Recommended_Replace_Vars();
1024
1025 // What is recommended depends on the current context.
1026 $post_type = $recommended_replace_vars->determine_for_post( $this->get_metabox_post() );
1027
1028 return $recommended_replace_vars->get_recommended_replacevars_for( $post_type );
1029 }
1030
1031 /**
1032 * Gets the custom replace variables for custom taxonomies and fields.
1033 *
1034 * @param WP_Post $post The post to check for custom taxonomies and fields.
1035 *
1036 * @return array Array containing all the replacement variables.
1037 */
1038 private function get_custom_replace_vars( $post ) {
1039 return [
1040 'custom_fields' => $this->get_custom_fields_replace_vars( $post ),
1041 'custom_taxonomies' => $this->get_custom_taxonomies_replace_vars( $post ),
1042 ];
1043 }
1044
1045 /**
1046 * Gets the custom replace variables for custom taxonomies.
1047 *
1048 * @param WP_Post $post The post to check for custom taxonomies.
1049 *
1050 * @return array Array containing all the replacement variables.
1051 */
1052 private function get_custom_taxonomies_replace_vars( $post ) {
1053 $taxonomies = get_object_taxonomies( $post, 'objects' );
1054 $custom_replace_vars = [];
1055
1056 foreach ( $taxonomies as $taxonomy_name => $taxonomy ) {
1057
1058 if ( is_string( $taxonomy ) ) { // If attachment, see https://core.trac.wordpress.org/ticket/37368 .
1059 $taxonomy_name = $taxonomy;
1060 $taxonomy = get_taxonomy( $taxonomy_name );
1061 }
1062
1063 if ( $taxonomy->_builtin && $taxonomy->public ) {
1064 continue;
1065 }
1066
1067 $custom_replace_vars[ $taxonomy_name ] = [
1068 'name' => $taxonomy->name,
1069 'description' => $taxonomy->description,
1070 ];
1071 }
1072
1073 return $custom_replace_vars;
1074 }
1075
1076 /**
1077 * Gets the custom replace variables for custom fields.
1078 *
1079 * @param WP_Post $post The post to check for custom fields.
1080 *
1081 * @return array Array containing all the replacement variables.
1082 */
1083 private function get_custom_fields_replace_vars( $post ) {
1084 $custom_replace_vars = [];
1085
1086 // If no post object is passed, return the empty custom_replace_vars array.
1087 if ( ! is_object( $post ) ) {
1088 return $custom_replace_vars;
1089 }
1090
1091 $custom_fields = get_post_custom( $post->ID );
1092
1093 // If $custom_fields is an empty string or generally not an array, return early.
1094 if ( ! is_array( $custom_fields ) ) {
1095 return $custom_replace_vars;
1096 }
1097
1098 foreach ( $custom_fields as $custom_field_name => $custom_field ) {
1099 // Skip private custom fields.
1100 if ( substr( $custom_field_name, 0, 1 ) === '_' ) {
1101 continue;
1102 }
1103
1104 // Skip custom field values that are serialized.
1105 if ( is_serialized( $custom_field[0] ) ) {
1106 continue;
1107 }
1108
1109 $custom_replace_vars[ $custom_field_name ] = $custom_field[0];
1110 }
1111
1112 return $custom_replace_vars;
1113 }
1114
1115 /**
1116 * Checks if the page is the post overview page.
1117 *
1118 * @param string $page The page to check for the post overview page.
1119 *
1120 * @return bool Whether or not the given page is the post overview page.
1121 */
1122 public static function is_post_overview( $page ) {
1123 return $page === 'edit.php';
1124 }
1125
1126 /**
1127 * Checks if the page is the post edit page.
1128 *
1129 * @param string $page The page to check for the post edit page.
1130 *
1131 * @return bool Whether or not the given page is the post edit page.
1132 */
1133 public static function is_post_edit( $page ) {
1134 return $page === 'post.php'
1135 || $page === 'post-new.php';
1136 }
1137
1138 /**
1139 * Retrieves the product title.
1140 *
1141 * @return string The product title.
1142 */
1143 protected function get_product_title() {
1144 return YoastSEO()->helpers->product->get_product_name();
1145 }
1146 }
1147