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

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

1,149 lines 37.8 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 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized in wp_verify_none.
715 if ( ! isset( $_POST['yoast_free_metabox_nonce'] ) || ! wp_verify_nonce( wp_unslash( $_POST['yoast_free_metabox_nonce'] ), 'yoast_free_metabox' ) ) {
716 return false;
717 }
718
719 if ( wp_is_post_revision( $post_id ) ) {
720 $post_id = wp_is_post_revision( $post_id );
721 }
722
723 /**
724 * Determine we're not accidentally updating a different post.
725 * We can't use filter_input here as the ID isn't available at this point, other than in the $_POST data.
726 */
727 if ( ! isset( $_POST['ID'] ) || $post_id !== (int) $_POST['ID'] ) {
728 return false;
729 }
730
731 clean_post_cache( $post_id );
732 $post = get_post( $post_id );
733
734 if ( ! is_object( $post ) ) {
735 // Non-existent post.
736 return false;
737 }
738
739 do_action( 'wpseo_save_compare_data', $post );
740
741 $social_fields = [];
742 if ( $this->social_is_enabled ) {
743 $social_fields = WPSEO_Meta::get_meta_field_defs( 'social' );
744 }
745
746 $meta_boxes = apply_filters( 'wpseo_save_metaboxes', [] );
747 $meta_boxes = array_merge(
748 $meta_boxes,
749 WPSEO_Meta::get_meta_field_defs( 'general', $post->post_type ),
750 WPSEO_Meta::get_meta_field_defs( 'advanced' ),
751 $social_fields,
752 WPSEO_Meta::get_meta_field_defs( 'schema', $post->post_type )
753 );
754
755 foreach ( $meta_boxes as $key => $meta_box ) {
756
757 // If analysis is disabled remove that analysis score value from the DB.
758 if ( $this->is_meta_value_disabled( $key ) ) {
759 WPSEO_Meta::delete( $key, $post_id );
760 continue;
761 }
762
763 $data = null;
764 $field_name = WPSEO_Meta::$form_prefix . $key;
765
766 if ( $meta_box['type'] === 'checkbox' ) {
767 $data = isset( $_POST[ $field_name ] ) ? 'on' : 'off';
768 }
769 else {
770 if ( isset( $_POST[ $field_name ] ) ) {
771 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- We're preparing to do just that.
772 $data = wp_unslash( $_POST[ $field_name ] );
773
774 // For multi-select.
775 if ( is_array( $data ) ) {
776 $data = array_map( [ 'WPSEO_Utils', 'sanitize_text_field' ], $data );
777 }
778
779 if ( is_string( $data ) ) {
780 $data = ( $key !== 'canonical' ) ? WPSEO_Utils::sanitize_text_field( $data ) : WPSEO_Utils::sanitize_url( $data );
781 }
782 }
783
784 // Reset options when no entry is present with multiselect - only applies to `meta-robots-adv` currently.
785 if ( ! isset( $_POST[ $field_name ] ) && ( $meta_box['type'] === 'multiselect' ) ) {
786 $data = [];
787 }
788 }
789
790 if ( $data !== null ) {
791 WPSEO_Meta::set_value( $key, $data, $post_id );
792 }
793 }
794
795 do_action( 'wpseo_saved_postdata' );
796 }
797
798 /**
799 * Determines if the given meta value key is disabled.
800 *
801 * @param string $key The key of the meta value.
802 *
803 * @return bool Whether the given meta value key is disabled.
804 */
805 public function is_meta_value_disabled( $key ) {
806 if ( $key === 'linkdex' && ! $this->seo_analysis->is_enabled() ) {
807 return true;
808 }
809
810 if ( $key === 'content_score' && ! $this->readability_analysis->is_enabled() ) {
811 return true;
812 }
813
814 return false;
815 }
816
817 /**
818 * Enqueues all the needed JS and CSS.
819 *
820 * @todo [JRF => whomever] Create css/metabox-mp6.css file and add it to the below allowed colors array when done.
821 */
822 public function enqueue() {
823 global $pagenow;
824
825 $asset_manager = new WPSEO_Admin_Asset_Manager();
826
827 $is_editor = self::is_post_overview( $pagenow ) || self::is_post_edit( $pagenow );
828
829 if ( self::is_post_overview( $pagenow ) ) {
830 $asset_manager->enqueue_style( 'edit-page' );
831 $asset_manager->enqueue_script( 'edit-page' );
832
833 return;
834 }
835
836 /* Filter 'wpseo_always_register_metaboxes_on_admin' documented in wpseo-main.php */
837 if ( ( $is_editor === false && apply_filters( 'wpseo_always_register_metaboxes_on_admin', false ) === false ) || $this->display_metabox() === false ) {
838 return;
839 }
840
841 $post_id = get_queried_object_id();
842 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
843 if ( empty( $post_id ) && isset( $_GET['post'] ) ) {
844 $post_id = sanitize_text_field( filter_input( INPUT_GET, 'post' ) );
845 }
846
847 if ( $post_id !== 0 ) {
848 // Enqueue files needed for upload functionality.
849 wp_enqueue_media( [ 'post' => $post_id ] );
850 }
851
852 $asset_manager->enqueue_style( 'metabox-css' );
853 $asset_manager->enqueue_style( 'scoring' );
854 $asset_manager->enqueue_style( 'select2' );
855 $asset_manager->enqueue_style( 'monorepo' );
856
857 $is_block_editor = WP_Screen::get()->is_block_editor();
858 $post_edit_handle = 'post-edit';
859 if ( ! $is_block_editor ) {
860 $post_edit_handle = 'post-edit-classic';
861 }
862 $asset_manager->enqueue_script( $post_edit_handle );
863 $asset_manager->enqueue_style( 'admin-css' );
864
865 /**
866 * Removes the emoji script as it is incompatible with both React and any
867 * contenteditable fields.
868 */
869 remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
870
871 $asset_manager->localize_script( $post_edit_handle, 'wpseoAdminL10n', WPSEO_Utils::get_admin_l10n() );
872
873 $plugins_script_data = [
874 'replaceVars' => [
875 'no_parent_text' => __( '(no parent)', 'wordpress-seo' ),
876 'replace_vars' => $this->get_replace_vars(),
877 'hidden_replace_vars' => $this->get_hidden_replace_vars(),
878 'recommended_replace_vars' => $this->get_recommended_replace_vars(),
879 'scope' => $this->determine_scope(),
880 'has_taxonomies' => $this->current_post_type_has_taxonomies(),
881 ],
882 'shortcodes' => [
883 'wpseo_filter_shortcodes_nonce' => wp_create_nonce( 'wpseo-filter-shortcodes' ),
884 'wpseo_shortcode_tags' => $this->get_valid_shortcode_tags(),
885 ],
886 ];
887
888 $worker_script_data = [
889 'url' => YoastSEO()->helpers->asset->get_asset_url( 'yoast-seo-analysis-worker' ),
890 'dependencies' => YoastSEO()->helpers->asset->get_dependency_urls_by_handle( 'yoast-seo-analysis-worker' ),
891 'keywords_assessment_url' => YoastSEO()->helpers->asset->get_asset_url( 'yoast-seo-used-keywords-assessment' ),
892 'log_level' => WPSEO_Utils::get_analysis_worker_log_level(),
893 ];
894
895 $alert_dismissal_action = YoastSEO()->classes->get( \Yoast\WP\SEO\Actions\Alert_Dismissal_Action::class );
896 $dismissed_alerts = $alert_dismissal_action->all_dismissed();
897
898 $script_data = [
899 // @todo replace this translation with JavaScript translations.
900 'media' => [ 'choose_image' => __( 'Use Image', 'wordpress-seo' ) ],
901 'metabox' => $this->get_metabox_script_data(),
902 'userLanguageCode' => WPSEO_Language_Utils::get_language( \get_user_locale() ),
903 'isPost' => true,
904 'isBlockEditor' => $is_block_editor,
905 'postStatus' => get_post_status( $post_id ),
906 'analysis' => [
907 'plugins' => $plugins_script_data,
908 'worker' => $worker_script_data,
909 'estimatedReadingTimeEnabled' => $this->estimated_reading_time_conditional->is_met(),
910 ],
911 'dismissedAlerts' => $dismissed_alerts,
912 ];
913
914 if ( post_type_supports( get_post_type(), 'thumbnail' ) ) {
915 $asset_manager->enqueue_style( 'featured-image' );
916
917 // @todo replace this translation with JavaScript translations.
918 $script_data['featuredImage'] = [
919 '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' ),
920 ];
921 }
922
923 $asset_manager->localize_script( $post_edit_handle, 'wpseoScriptData', $script_data );
924 $asset_manager->enqueue_user_language_script();
925 }
926
927 /**
928 * Returns post in metabox context.
929 *
930 * @return WP_Post|array
931 */
932 protected function get_metabox_post() {
933 if ( $this->post !== null ) {
934 return $this->post;
935 }
936
937 $post = filter_input( INPUT_GET, 'post' );
938 if ( ! empty( $post ) ) {
939 $post_id = (int) WPSEO_Utils::validate_int( $post );
940
941 $this->post = get_post( $post_id );
942
943 return $this->post;
944 }
945
946 if ( isset( $GLOBALS['post'] ) ) {
947 $this->post = $GLOBALS['post'];
948
949 return $this->post;
950 }
951
952 return [];
953 }
954
955 /**
956 * Returns an array with shortcode tags for all registered shortcodes.
957 *
958 * @return array
959 */
960 private function get_valid_shortcode_tags() {
961 $shortcode_tags = [];
962
963 foreach ( $GLOBALS['shortcode_tags'] as $tag => $description ) {
964 $shortcode_tags[] = $tag;
965 }
966
967 return $shortcode_tags;
968 }
969
970 /**
971 * Prepares the replace vars for localization.
972 *
973 * @return array Replace vars.
974 */
975 private function get_replace_vars() {
976 $cached_replacement_vars = [];
977
978 $vars_to_cache = [
979 'date',
980 'id',
981 'sitename',
982 'sitedesc',
983 'sep',
984 'page',
985 'currentdate',
986 'currentyear',
987 'currentmonth',
988 'currentday',
989 'post_year',
990 'post_month',
991 'post_day',
992 'name',
993 'author_first_name',
994 'author_last_name',
995 'permalink',
996 'post_content',
997 'category_title',
998 'tag',
999 'category',
1000 ];
1001
1002 foreach ( $vars_to_cache as $var ) {
1003 $cached_replacement_vars[ $var ] = wpseo_replace_vars( '%%' . $var . '%%', $this->get_metabox_post() );
1004 }
1005
1006 // Merge custom replace variables with the WordPress ones.
1007 return array_merge( $cached_replacement_vars, $this->get_custom_replace_vars( $this->get_metabox_post() ) );
1008 }
1009
1010 /**
1011 * Returns the list of replace vars that should be hidden inside the editor.
1012 *
1013 * @return string[] The hidden replace vars.
1014 */
1015 protected function get_hidden_replace_vars() {
1016 return ( new WPSEO_Replace_Vars() )->get_hidden_replace_vars();
1017 }
1018
1019 /**
1020 * Prepares the recommended replace vars for localization.
1021 *
1022 * @return array Recommended replacement variables.
1023 */
1024 private function get_recommended_replace_vars() {
1025 $recommended_replace_vars = new WPSEO_Admin_Recommended_Replace_Vars();
1026
1027 // What is recommended depends on the current context.
1028 $post_type = $recommended_replace_vars->determine_for_post( $this->get_metabox_post() );
1029
1030 return $recommended_replace_vars->get_recommended_replacevars_for( $post_type );
1031 }
1032
1033 /**
1034 * Gets the custom replace variables for custom taxonomies and fields.
1035 *
1036 * @param WP_Post $post The post to check for custom taxonomies and fields.
1037 *
1038 * @return array Array containing all the replacement variables.
1039 */
1040 private function get_custom_replace_vars( $post ) {
1041 return [
1042 'custom_fields' => $this->get_custom_fields_replace_vars( $post ),
1043 'custom_taxonomies' => $this->get_custom_taxonomies_replace_vars( $post ),
1044 ];
1045 }
1046
1047 /**
1048 * Gets the custom replace variables for custom taxonomies.
1049 *
1050 * @param WP_Post $post The post to check for custom taxonomies.
1051 *
1052 * @return array Array containing all the replacement variables.
1053 */
1054 private function get_custom_taxonomies_replace_vars( $post ) {
1055 $taxonomies = get_object_taxonomies( $post, 'objects' );
1056 $custom_replace_vars = [];
1057
1058 foreach ( $taxonomies as $taxonomy_name => $taxonomy ) {
1059
1060 if ( is_string( $taxonomy ) ) { // If attachment, see https://core.trac.wordpress.org/ticket/37368 .
1061 $taxonomy_name = $taxonomy;
1062 $taxonomy = get_taxonomy( $taxonomy_name );
1063 }
1064
1065 if ( $taxonomy->_builtin && $taxonomy->public ) {
1066 continue;
1067 }
1068
1069 $custom_replace_vars[ $taxonomy_name ] = [
1070 'name' => $taxonomy->name,
1071 'description' => $taxonomy->description,
1072 ];
1073 }
1074
1075 return $custom_replace_vars;
1076 }
1077
1078 /**
1079 * Gets the custom replace variables for custom fields.
1080 *
1081 * @param WP_Post $post The post to check for custom fields.
1082 *
1083 * @return array Array containing all the replacement variables.
1084 */
1085 private function get_custom_fields_replace_vars( $post ) {
1086 $custom_replace_vars = [];
1087
1088 // If no post object is passed, return the empty custom_replace_vars array.
1089 if ( ! is_object( $post ) ) {
1090 return $custom_replace_vars;
1091 }
1092
1093 $custom_fields = get_post_custom( $post->ID );
1094
1095 // If $custom_fields is an empty string or generally not an array, return early.
1096 if ( ! is_array( $custom_fields ) ) {
1097 return $custom_replace_vars;
1098 }
1099
1100 foreach ( $custom_fields as $custom_field_name => $custom_field ) {
1101 // Skip private custom fields.
1102 if ( substr( $custom_field_name, 0, 1 ) === '_' ) {
1103 continue;
1104 }
1105
1106 // Skip custom field values that are serialized.
1107 if ( is_serialized( $custom_field[0] ) ) {
1108 continue;
1109 }
1110
1111 $custom_replace_vars[ $custom_field_name ] = $custom_field[0];
1112 }
1113
1114 return $custom_replace_vars;
1115 }
1116
1117 /**
1118 * Checks if the page is the post overview page.
1119 *
1120 * @param string $page The page to check for the post overview page.
1121 *
1122 * @return bool Whether or not the given page is the post overview page.
1123 */
1124 public static function is_post_overview( $page ) {
1125 return $page === 'edit.php';
1126 }
1127
1128 /**
1129 * Checks if the page is the post edit page.
1130 *
1131 * @param string $page The page to check for the post edit page.
1132 *
1133 * @return bool Whether or not the given page is the post edit page.
1134 */
1135 public static function is_post_edit( $page ) {
1136 return $page === 'post.php'
1137 || $page === 'post-new.php';
1138 }
1139
1140 /**
1141 * Retrieves the product title.
1142 *
1143 * @return string The product title.
1144 */
1145 protected function get_product_title() {
1146 return YoastSEO()->helpers->product->get_product_name();
1147 }
1148 }
1149