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

1,155 lines 38.5 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 /* translators: %s expands to the post type name. */
208 WPSEO_Meta::$meta_fields['advanced']['wordproof_timestamp']['title'] = __( 'Timestamp this %s', 'wordpress-seo' );
209 WPSEO_Meta::$meta_fields['advanced']['wordproof_timestamp']['description'] = __( 'Use WordProof to timestamp this page to comply with legal regulations and join the fight for a more transparant and accountable internet.', 'wordpress-seo' );
210 WPSEO_Meta::$meta_fields['advanced']['wordproof_timestamp']['options']['0'] = __( 'Off', 'wordpress-seo' );
211 WPSEO_Meta::$meta_fields['advanced']['wordproof_timestamp']['options']['1'] = __( 'On', 'wordpress-seo' );
212 WPSEO_Meta::$meta_fields['advanced']['wordproof_timestamp']['type'] = 'hidden';
213
214 WPSEO_Meta::$meta_fields['advanced']['redirect']['title'] = __( '301 Redirect', 'wordpress-seo' );
215 WPSEO_Meta::$meta_fields['advanced']['redirect']['description'] = __( 'The URL that this page should redirect to.', 'wordpress-seo' );
216
217 do_action( 'wpseo_tab_translate' );
218 }
219
220 /**
221 * Determines whether the metabox should be shown for the passed identifier.
222 *
223 * By default the check is done for post types, but can also be used for taxonomies.
224 *
225 * @param string|null $identifier The identifier to check.
226 * @param string $type The type of object to check. Defaults to post_type.
227 *
228 * @return bool Whether or not the metabox should be displayed.
229 */
230 public function display_metabox( $identifier = null, $type = 'post_type' ) {
231 return WPSEO_Utils::is_metabox_active( $identifier, $type );
232 }
233
234 /**
235 * Adds the Yoast SEO meta box to the edit boxes in the edit post, page,
236 * attachment, and custom post types pages.
237 *
238 * @return void
239 */
240 public function add_meta_box() {
241 $post_types = WPSEO_Post_Type::get_accessible_post_types();
242 $post_types = array_filter( $post_types, [ $this, 'display_metabox' ] );
243
244 if ( ! is_array( $post_types ) || $post_types === [] ) {
245 return;
246 }
247
248 $product_title = $this->get_product_title();
249
250 foreach ( $post_types as $post_type ) {
251 add_filter( "postbox_classes_{$post_type}_wpseo_meta", [ $this, 'wpseo_metabox_class' ] );
252
253 add_meta_box(
254 'wpseo_meta',
255 $product_title,
256 [ $this, 'meta_box' ],
257 $post_type,
258 'normal',
259 apply_filters( 'wpseo_metabox_prio', 'high' ),
260 [ '__block_editor_compatible_meta_box' => true ]
261 );
262 }
263 }
264
265 /**
266 * Adds CSS classes to the meta box.
267 *
268 * @param array $classes An array of postbox CSS classes.
269 *
270 * @return array List of classes that will be applied to the editbox container.
271 */
272 public function wpseo_metabox_class( $classes ) {
273 $classes[] = 'yoast wpseo-metabox';
274
275 return $classes;
276 }
277
278 /**
279 * Passes variables to js for use with the post-scraper.
280 *
281 * @return array
282 */
283 public function get_metabox_script_data() {
284 $permalink = '';
285
286 if ( is_object( $this->get_metabox_post() ) ) {
287 $permalink = get_sample_permalink( $this->get_metabox_post()->ID );
288 $permalink = $permalink[0];
289 }
290
291 $post_formatter = new WPSEO_Metabox_Formatter(
292 new WPSEO_Post_Metabox_Formatter( $this->get_metabox_post(), [], $permalink )
293 );
294
295 $values = $post_formatter->get_values();
296
297 /** This filter is documented in admin/filters/class-cornerstone-filter.php. */
298 $post_types = apply_filters( 'wpseo_cornerstone_post_types', WPSEO_Post_Type::get_accessible_post_types() );
299 if ( $values['cornerstoneActive'] && ! in_array( $this->get_metabox_post()->post_type, $post_types, true ) ) {
300 $values['cornerstoneActive'] = false;
301 }
302
303 if ( $values['semrushIntegrationActive'] && $this->post->post_type === 'attachment' ) {
304 $values['semrushIntegrationActive'] = 0;
305 }
306
307 if ( $values['wincherIntegrationActive'] && $this->post->post_type === 'attachment' ) {
308 $values['wincherIntegrationActive'] = 0;
309 }
310
311 return $values;
312 }
313
314 /**
315 * Determines whether or not the current post type has registered taxonomies.
316 *
317 * @return bool Whether the current post type has taxonomies.
318 */
319 private function current_post_type_has_taxonomies() {
320 $post_taxonomies = get_object_taxonomies( get_post_type() );
321
322 return ! empty( $post_taxonomies );
323 }
324
325 /**
326 * Determines the scope based on the post type.
327 * This can be used by the replacevar plugin to determine if a replacement needs to be executed.
328 *
329 * @return string String describing the current scope.
330 */
331 private function determine_scope() {
332 if ( $this->get_metabox_post()->post_type === 'page' ) {
333 return 'page';
334 }
335
336 return 'post';
337 }
338
339 /**
340 * Outputs the meta box.
341 */
342 public function meta_box() {
343 $this->render_hidden_fields();
344 $this->render_tabs();
345 }
346
347 /**
348 * Renders the metabox hidden fields.
349 *
350 * @return void
351 */
352 protected function render_hidden_fields() {
353 wp_nonce_field( 'yoast_free_metabox', 'yoast_free_metabox_nonce' );
354
355 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output escaped in class.
356 echo new Meta_Fields_Presenter( $this->get_metabox_post(), 'general' );
357
358 if ( $this->is_advanced_metadata_enabled ) {
359 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output escaped in class.
360 echo new Meta_Fields_Presenter( $this->get_metabox_post(), 'advanced' );
361 }
362
363 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output escaped in class.
364 echo new Meta_Fields_Presenter( $this->get_metabox_post(), 'schema', $this->get_metabox_post()->post_type );
365
366 if ( $this->social_is_enabled ) {
367 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output escaped in class.
368 echo new Meta_Fields_Presenter( $this->get_metabox_post(), 'social' );
369 }
370
371 /**
372 * Filter: 'wpseo_content_meta_section_content' - Allow filtering the metabox content before outputting.
373 *
374 * @api string $post_content The metabox content string.
375 */
376 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output should be escaped in the filter.
377 echo apply_filters( 'wpseo_content_meta_section_content', '' );
378 }
379
380 /**
381 * Renders the metabox tabs.
382 *
383 * @return void
384 */
385 protected function render_tabs() {
386 echo '<div class="wpseo-metabox-content">';
387 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Reason: $this->get_product_title() returns a hard-coded string.
388 printf( '<div class="wpseo-metabox-menu"><ul role="tablist" class="yoast-aria-tabs" aria-label="%s">', $this->get_product_title() );
389
390 $tabs = $this->get_tabs();
391
392 foreach ( $tabs as $tab ) {
393 if ( $tab->name === 'premium' ) {
394 continue;
395 }
396
397 $tab->display_link();
398 }
399
400 echo '</ul></div>';
401
402 foreach ( $tabs as $tab ) {
403 $tab->display_content();
404 }
405
406 echo '</div>';
407 }
408
409 /**
410 * Returns the relevant metabox tabs for the current view.
411 *
412 * @return WPSEO_Metabox_Section[]
413 */
414 private function get_tabs() {
415 $tabs = [];
416
417 $label = __( 'SEO', 'wordpress-seo' );
418 if ( $this->seo_analysis->is_enabled() ) {
419 $label = '<span class="wpseo-score-icon-container" id="wpseo-seo-score-icon"></span>' . $label;
420 }
421 $tabs[] = new WPSEO_Metabox_Section_React( 'content', $label );
422
423 if ( $this->readability_analysis->is_enabled() ) {
424 $tabs[] = new WPSEO_Metabox_Section_Readability();
425 }
426
427 if ( $this->is_advanced_metadata_enabled ) {
428 $tabs[] = new WPSEO_Metabox_Section_React(
429 'schema',
430 '<span class="wpseo-schema-icon"></span>' . __( 'Schema', 'wordpress-seo' ),
431 ''
432 );
433 }
434
435 if ( $this->social_is_enabled ) {
436 $tabs[] = new WPSEO_Metabox_Section_React(
437 'social',
438 '<span class="dashicons dashicons-share"></span>' . __( 'Social', 'wordpress-seo' ),
439 '',
440 [
441 'html_after' => '<div id="wpseo-section-social"></div>',
442 ]
443 );
444 }
445
446 $tabs = array_merge( $tabs, $this->get_additional_tabs() );
447
448 return $tabs;
449 }
450
451 /**
452 * Returns the metabox tabs that have been added by other plugins.
453 *
454 * @return WPSEO_Metabox_Section_Additional[]
455 */
456 protected function get_additional_tabs() {
457 $tabs = [];
458
459 /**
460 * Private filter: 'yoast_free_additional_metabox_sections'.
461 *
462 * Meant for internal use only. Allows adding additional tabs to the Yoast SEO metabox.
463 *
464 * @since 11.9
465 *
466 * @param array[] $tabs {
467 * An array of arrays with tab specifications.
468 *
469 * @type array $tab {
470 * A tab specification.
471 *
472 * @type string $name The name of the tab. Used in the HTML IDs, href and aria properties.
473 * @type string $link_content The content of the tab link.
474 * @type string $content The content of the tab.
475 * @type array $options {
476 * Optional. Extra options.
477 *
478 * @type string $link_class Optional. The class for the tab link.
479 * @type string $link_aria_label Optional. The aria label of the tab link.
480 * }
481 * }
482 * }
483 */
484 $requested_tabs = apply_filters( 'yoast_free_additional_metabox_sections', [] );
485
486 foreach ( $requested_tabs as $tab ) {
487 if ( is_array( $tab ) && array_key_exists( 'name', $tab ) && array_key_exists( 'link_content', $tab ) && array_key_exists( 'content', $tab ) ) {
488 $options = array_key_exists( 'options', $tab ) ? $tab['options'] : [];
489 $tabs[] = new WPSEO_Metabox_Section_Additional(
490 $tab['name'],
491 $tab['link_content'],
492 $tab['content'],
493 $options
494 );
495 }
496 }
497
498 return $tabs;
499 }
500
501 /**
502 * Adds a line in the meta box.
503 *
504 * @todo [JRF] Check if $class is added appropriately everywhere.
505 *
506 * @param array $meta_field_def Contains the vars based on which output is generated.
507 * @param string $key Internal key (without prefix).
508 *
509 * @return string
510 */
511 public function do_meta_box( $meta_field_def, $key = '' ) {
512 $content = '';
513 $esc_form_key = esc_attr( WPSEO_Meta::$form_prefix . $key );
514 $meta_value = WPSEO_Meta::get_value( $key, $this->get_metabox_post()->ID );
515
516 $class = '';
517 if ( isset( $meta_field_def['class'] ) && $meta_field_def['class'] !== '' ) {
518 $class = ' ' . $meta_field_def['class'];
519 }
520
521 $placeholder = '';
522 if ( isset( $meta_field_def['placeholder'] ) && $meta_field_def['placeholder'] !== '' ) {
523 $placeholder = $meta_field_def['placeholder'];
524 }
525
526 $aria_describedby = '';
527 $description = '';
528 if ( isset( $meta_field_def['description'] ) ) {
529 $aria_describedby = ' aria-describedby="' . $esc_form_key . '-desc"';
530 $description = '<p id="' . $esc_form_key . '-desc" class="yoast-metabox__description">' . $meta_field_def['description'] . '</p>';
531 }
532
533 // Add a hide_on_pages option that returns nothing when the field is rendered on a page.
534 if ( isset( $meta_field_def['hide_on_pages'] ) && $meta_field_def['hide_on_pages'] && get_post_type() === 'page' ) {
535 return '';
536 }
537
538 switch ( $meta_field_def['type'] ) {
539 case 'text':
540 $ac = '';
541 if ( isset( $meta_field_def['autocomplete'] ) && $meta_field_def['autocomplete'] === false ) {
542 $ac = 'autocomplete="off" ';
543 }
544 if ( $placeholder !== '' ) {
545 $placeholder = ' placeholder="' . esc_attr( $placeholder ) . '"';
546 }
547 $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 . '/>';
548 break;
549
550 case 'url':
551 if ( $placeholder !== '' ) {
552 $placeholder = ' placeholder="' . esc_attr( $placeholder ) . '"';
553 }
554 $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 . '/>';
555 break;
556
557 case 'textarea':
558 $rows = 3;
559 if ( isset( $meta_field_def['rows'] ) && $meta_field_def['rows'] > 0 ) {
560 $rows = $meta_field_def['rows'];
561 }
562 $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>';
563 break;
564
565 case 'hidden':
566 $default = '';
567 if ( isset( $meta_field_def['default'] ) ) {
568 $default = sprintf( ' data-default="%s"', esc_attr( $meta_field_def['default'] ) );
569 }
570 $content .= '<input type="hidden" id="' . $esc_form_key . '" name="' . $esc_form_key . '" value="' . esc_attr( $meta_value ) . '"' . $default . '/>' . "\n";
571 break;
572 case 'select':
573 if ( isset( $meta_field_def['options'] ) && is_array( $meta_field_def['options'] ) && $meta_field_def['options'] !== [] ) {
574 $content .= '<select name="' . $esc_form_key . '" id="' . $esc_form_key . '" class="yoast' . $class . '">';
575 foreach ( $meta_field_def['options'] as $val => $option ) {
576 $selected = selected( $meta_value, $val, false );
577 $content .= '<option ' . $selected . ' value="' . esc_attr( $val ) . '">' . esc_html( $option ) . '</option>';
578 }
579 unset( $val, $option, $selected );
580 $content .= '</select>';
581 }
582 break;
583
584 case 'multiselect':
585 if ( isset( $meta_field_def['options'] ) && is_array( $meta_field_def['options'] ) && $meta_field_def['options'] !== [] ) {
586
587 // Set $meta_value as $selected_arr.
588 $selected_arr = $meta_value;
589
590 // If the multiselect field is 'meta-robots-adv' we should explode on ,.
591 if ( $key === 'meta-robots-adv' ) {
592 $selected_arr = explode( ',', $meta_value );
593 }
594
595 if ( ! is_array( $selected_arr ) ) {
596 $selected_arr = (array) $selected_arr;
597 }
598
599 $options_count = count( $meta_field_def['options'] );
600
601 // This select now uses Select2.
602 $content .= '<select multiple="multiple" size="' . esc_attr( $options_count ) . '" name="' . $esc_form_key . '[]" id="' . $esc_form_key . '" class="yoast' . $class . '"' . $aria_describedby . '>';
603 foreach ( $meta_field_def['options'] as $val => $option ) {
604 $selected = '';
605 if ( in_array( $val, $selected_arr, true ) ) {
606 $selected = ' selected="selected"';
607 }
608 $content .= '<option ' . $selected . ' value="' . esc_attr( $val ) . '">' . esc_html( $option ) . '</option>';
609 }
610 $content .= '</select>';
611 unset( $val, $option, $selected, $selected_arr, $options_count );
612 }
613 break;
614
615 case 'checkbox':
616 $checked = checked( $meta_value, 'on', false );
617 $expl = ( isset( $meta_field_def['expl'] ) ) ? esc_html( $meta_field_def['expl'] ) : '';
618 $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>';
619 unset( $checked, $expl );
620 break;
621
622 case 'radio':
623 if ( isset( $meta_field_def['options'] ) && is_array( $meta_field_def['options'] ) && $meta_field_def['options'] !== [] ) {
624 foreach ( $meta_field_def['options'] as $val => $option ) {
625 $checked = checked( $meta_value, $val, false );
626 $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> ';
627 }
628 unset( $val, $option, $checked );
629 }
630 break;
631
632 case 'upload':
633 $content .= '<input' .
634 ' id="' . $esc_form_key . '"' .
635 ' type="text"' .
636 ' size="36"' .
637 ' class="' . $class . '"' .
638 ' name="' . $esc_form_key . '"' .
639 ' value="' . esc_attr( $meta_value ) . '"' . $aria_describedby .
640 ' readonly="readonly"' .
641 ' /> ';
642 $content .= '<input' .
643 ' id="' . esc_attr( $esc_form_key ) . '_button"' .
644 ' class="wpseo_image_upload_button button"' .
645 ' data-target="' . esc_attr( $esc_form_key ) . '"' .
646 ' data-target-id="' . esc_attr( $esc_form_key ) . '-id"' .
647 ' type="button"' .
648 ' value="' . esc_attr__( 'Upload Image', 'wordpress-seo' ) . '"' .
649 ' /> ';
650 $content .= '<input' .
651 ' class="wpseo_image_remove_button button"' .
652 ' type="button"' .
653 ' value="' . esc_attr__( 'Clear Image', 'wordpress-seo' ) . '"' .
654 ' />';
655 break;
656 }
657
658 $html = '';
659 if ( $content === '' ) {
660 $content = apply_filters( 'wpseo_do_meta_box_field_' . $key, $content, $meta_value, $esc_form_key, $meta_field_def, $key );
661 }
662
663 if ( $content !== '' ) {
664
665 $title = esc_html( $meta_field_def['title'] );
666
667 // By default, use the field title as a label element.
668 $label = '<label for="' . $esc_form_key . '">' . $title . '</label>';
669
670 // Set the inline help and help panel, if any.
671 $help_button = '';
672 $help_panel = '';
673 if ( isset( $meta_field_def['help'] ) && $meta_field_def['help'] !== '' ) {
674 $help = new WPSEO_Admin_Help_Panel( $key, $meta_field_def['help-button'], $meta_field_def['help'] );
675 $help_button = $help->get_button_html();
676 $help_panel = $help->get_panel_html();
677 }
678
679 // If it's a set of radio buttons, output proper fieldset and legend.
680 if ( $meta_field_def['type'] === 'radio' ) {
681 return '<fieldset><legend>' . $title . '</legend>' . $help_button . $help_panel . $content . $description . '</fieldset>';
682 }
683
684 // If it's a single checkbox, ignore the title.
685 if ( $meta_field_def['type'] === 'checkbox' ) {
686 $label = '';
687 }
688
689 // Other meta box content or form fields.
690 if ( $meta_field_def['type'] === 'hidden' ) {
691 $html = $content;
692 }
693 else {
694 $html = $label . $description . $help_button . $help_panel . $content;
695 }
696 }
697
698 return $html;
699 }
700
701 /**
702 * Saves the WP SEO metadata for posts.
703 *
704 * {@internal $_POST parameters are validated via sanitize_post_meta().}}
705 *
706 * @param int $post_id Post ID.
707 *
708 * @return bool|void Boolean false if invalid save post request.
709 */
710 public function save_postdata( $post_id ) {
711 // Bail if this is a multisite installation and the site has been switched.
712 if ( is_multisite() && ms_is_switched() ) {
713 return false;
714 }
715
716 if ( $post_id === null ) {
717 return false;
718 }
719
720 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized in wp_verify_none.
721 if ( ! isset( $_POST['yoast_free_metabox_nonce'] ) || ! wp_verify_nonce( wp_unslash( $_POST['yoast_free_metabox_nonce'] ), 'yoast_free_metabox' ) ) {
722 return false;
723 }
724
725 if ( wp_is_post_revision( $post_id ) ) {
726 $post_id = wp_is_post_revision( $post_id );
727 }
728
729 /**
730 * Determine we're not accidentally updating a different post.
731 * We can't use filter_input here as the ID isn't available at this point, other than in the $_POST data.
732 */
733 if ( ! isset( $_POST['ID'] ) || $post_id !== (int) $_POST['ID'] ) {
734 return false;
735 }
736
737 clean_post_cache( $post_id );
738 $post = get_post( $post_id );
739
740 if ( ! is_object( $post ) ) {
741 // Non-existent post.
742 return false;
743 }
744
745 do_action( 'wpseo_save_compare_data', $post );
746
747 $social_fields = [];
748 if ( $this->social_is_enabled ) {
749 $social_fields = WPSEO_Meta::get_meta_field_defs( 'social' );
750 }
751
752 $meta_boxes = apply_filters( 'wpseo_save_metaboxes', [] );
753 $meta_boxes = array_merge(
754 $meta_boxes,
755 WPSEO_Meta::get_meta_field_defs( 'general', $post->post_type ),
756 WPSEO_Meta::get_meta_field_defs( 'advanced' ),
757 $social_fields,
758 WPSEO_Meta::get_meta_field_defs( 'schema', $post->post_type )
759 );
760
761 foreach ( $meta_boxes as $key => $meta_box ) {
762
763 // If analysis is disabled remove that analysis score value from the DB.
764 if ( $this->is_meta_value_disabled( $key ) ) {
765 WPSEO_Meta::delete( $key, $post_id );
766 continue;
767 }
768
769 $data = null;
770 $field_name = WPSEO_Meta::$form_prefix . $key;
771
772 if ( $meta_box['type'] === 'checkbox' ) {
773 $data = isset( $_POST[ $field_name ] ) ? 'on' : 'off';
774 }
775 else {
776 if ( isset( $_POST[ $field_name ] ) ) {
777 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- We're preparing to do just that.
778 $data = wp_unslash( $_POST[ $field_name ] );
779
780 // For multi-select.
781 if ( is_array( $data ) ) {
782 $data = array_map( [ 'WPSEO_Utils', 'sanitize_text_field' ], $data );
783 }
784
785 if ( is_string( $data ) ) {
786 $data = ( $key !== 'canonical' ) ? WPSEO_Utils::sanitize_text_field( $data ) : WPSEO_Utils::sanitize_url( $data );
787 }
788 }
789
790 // Reset options when no entry is present with multiselect - only applies to `meta-robots-adv` currently.
791 if ( ! isset( $_POST[ $field_name ] ) && ( $meta_box['type'] === 'multiselect' ) ) {
792 $data = [];
793 }
794 }
795
796 if ( $data !== null ) {
797 WPSEO_Meta::set_value( $key, $data, $post_id );
798 }
799 }
800
801 do_action( 'wpseo_saved_postdata' );
802 }
803
804 /**
805 * Determines if the given meta value key is disabled.
806 *
807 * @param string $key The key of the meta value.
808 *
809 * @return bool Whether the given meta value key is disabled.
810 */
811 public function is_meta_value_disabled( $key ) {
812 if ( $key === 'linkdex' && ! $this->seo_analysis->is_enabled() ) {
813 return true;
814 }
815
816 if ( $key === 'content_score' && ! $this->readability_analysis->is_enabled() ) {
817 return true;
818 }
819
820 return false;
821 }
822
823 /**
824 * Enqueues all the needed JS and CSS.
825 *
826 * @todo [JRF => whomever] Create css/metabox-mp6.css file and add it to the below allowed colors array when done.
827 */
828 public function enqueue() {
829 global $pagenow;
830
831 $asset_manager = new WPSEO_Admin_Asset_Manager();
832
833 $is_editor = self::is_post_overview( $pagenow ) || self::is_post_edit( $pagenow );
834
835 if ( self::is_post_overview( $pagenow ) ) {
836 $asset_manager->enqueue_style( 'edit-page' );
837 $asset_manager->enqueue_script( 'edit-page' );
838
839 return;
840 }
841
842 /* Filter 'wpseo_always_register_metaboxes_on_admin' documented in wpseo-main.php */
843 if ( ( $is_editor === false && apply_filters( 'wpseo_always_register_metaboxes_on_admin', false ) === false ) || $this->display_metabox() === false ) {
844 return;
845 }
846
847 $post_id = get_queried_object_id();
848 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
849 if ( empty( $post_id ) && isset( $_GET['post'] ) ) {
850 $post_id = sanitize_text_field( filter_input( INPUT_GET, 'post' ) );
851 }
852
853 if ( $post_id !== 0 ) {
854 // Enqueue files needed for upload functionality.
855 wp_enqueue_media( [ 'post' => $post_id ] );
856 }
857
858 $asset_manager->enqueue_style( 'metabox-css' );
859 $asset_manager->enqueue_style( 'scoring' );
860 $asset_manager->enqueue_style( 'select2' );
861 $asset_manager->enqueue_style( 'monorepo' );
862
863 $is_block_editor = WP_Screen::get()->is_block_editor();
864 $post_edit_handle = 'post-edit';
865 if ( ! $is_block_editor ) {
866 $post_edit_handle = 'post-edit-classic';
867 }
868 $asset_manager->enqueue_script( $post_edit_handle );
869 $asset_manager->enqueue_style( 'admin-css' );
870
871 /**
872 * Removes the emoji script as it is incompatible with both React and any
873 * contenteditable fields.
874 */
875 remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
876
877 $asset_manager->localize_script( $post_edit_handle, 'wpseoAdminL10n', WPSEO_Utils::get_admin_l10n() );
878
879 $plugins_script_data = [
880 'replaceVars' => [
881 'no_parent_text' => __( '(no parent)', 'wordpress-seo' ),
882 'replace_vars' => $this->get_replace_vars(),
883 'hidden_replace_vars' => $this->get_hidden_replace_vars(),
884 'recommended_replace_vars' => $this->get_recommended_replace_vars(),
885 'scope' => $this->determine_scope(),
886 'has_taxonomies' => $this->current_post_type_has_taxonomies(),
887 ],
888 'shortcodes' => [
889 'wpseo_filter_shortcodes_nonce' => wp_create_nonce( 'wpseo-filter-shortcodes' ),
890 'wpseo_shortcode_tags' => $this->get_valid_shortcode_tags(),
891 ],
892 ];
893
894 $worker_script_data = [
895 'url' => YoastSEO()->helpers->asset->get_asset_url( 'yoast-seo-analysis-worker' ),
896 'dependencies' => YoastSEO()->helpers->asset->get_dependency_urls_by_handle( 'yoast-seo-analysis-worker' ),
897 'keywords_assessment_url' => YoastSEO()->helpers->asset->get_asset_url( 'yoast-seo-used-keywords-assessment' ),
898 'log_level' => WPSEO_Utils::get_analysis_worker_log_level(),
899 ];
900
901 $alert_dismissal_action = YoastSEO()->classes->get( \Yoast\WP\SEO\Actions\Alert_Dismissal_Action::class );
902 $dismissed_alerts = $alert_dismissal_action->all_dismissed();
903
904 $script_data = [
905 // @todo replace this translation with JavaScript translations.
906 'media' => [ 'choose_image' => __( 'Use Image', 'wordpress-seo' ) ],
907 'metabox' => $this->get_metabox_script_data(),
908 'userLanguageCode' => WPSEO_Language_Utils::get_language( \get_user_locale() ),
909 'isPost' => true,
910 'isBlockEditor' => $is_block_editor,
911 'postStatus' => get_post_status( $post_id ),
912 'analysis' => [
913 'plugins' => $plugins_script_data,
914 'worker' => $worker_script_data,
915 'estimatedReadingTimeEnabled' => $this->estimated_reading_time_conditional->is_met(),
916 ],
917 'dismissedAlerts' => $dismissed_alerts,
918 ];
919
920 if ( post_type_supports( get_post_type(), 'thumbnail' ) ) {
921 $asset_manager->enqueue_style( 'featured-image' );
922
923 // @todo replace this translation with JavaScript translations.
924 $script_data['featuredImage'] = [
925 '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' ),
926 ];
927 }
928
929 $asset_manager->localize_script( $post_edit_handle, 'wpseoScriptData', $script_data );
930 $asset_manager->enqueue_user_language_script();
931 }
932
933 /**
934 * Returns post in metabox context.
935 *
936 * @return WP_Post|array
937 */
938 protected function get_metabox_post() {
939 if ( $this->post !== null ) {
940 return $this->post;
941 }
942
943 $post = filter_input( INPUT_GET, 'post' );
944 if ( ! empty( $post ) ) {
945 $post_id = (int) WPSEO_Utils::validate_int( $post );
946
947 $this->post = get_post( $post_id );
948
949 return $this->post;
950 }
951
952 if ( isset( $GLOBALS['post'] ) ) {
953 $this->post = $GLOBALS['post'];
954
955 return $this->post;
956 }
957
958 return [];
959 }
960
961 /**
962 * Returns an array with shortcode tags for all registered shortcodes.
963 *
964 * @return array
965 */
966 private function get_valid_shortcode_tags() {
967 $shortcode_tags = [];
968
969 foreach ( $GLOBALS['shortcode_tags'] as $tag => $description ) {
970 $shortcode_tags[] = $tag;
971 }
972
973 return $shortcode_tags;
974 }
975
976 /**
977 * Prepares the replace vars for localization.
978 *
979 * @return array Replace vars.
980 */
981 private function get_replace_vars() {
982 $cached_replacement_vars = [];
983
984 $vars_to_cache = [
985 'date',
986 'id',
987 'sitename',
988 'sitedesc',
989 'sep',
990 'page',
991 'currentdate',
992 'currentyear',
993 'currentmonth',
994 'currentday',
995 'post_year',
996 'post_month',
997 'post_day',
998 'name',
999 'author_first_name',
1000 'author_last_name',
1001 'permalink',
1002 'post_content',
1003 'category_title',
1004 'tag',
1005 'category',
1006 ];
1007
1008 foreach ( $vars_to_cache as $var ) {
1009 $cached_replacement_vars[ $var ] = wpseo_replace_vars( '%%' . $var . '%%', $this->get_metabox_post() );
1010 }
1011
1012 // Merge custom replace variables with the WordPress ones.
1013 return array_merge( $cached_replacement_vars, $this->get_custom_replace_vars( $this->get_metabox_post() ) );
1014 }
1015
1016 /**
1017 * Returns the list of replace vars that should be hidden inside the editor.
1018 *
1019 * @return string[] The hidden replace vars.
1020 */
1021 protected function get_hidden_replace_vars() {
1022 return ( new WPSEO_Replace_Vars() )->get_hidden_replace_vars();
1023 }
1024
1025 /**
1026 * Prepares the recommended replace vars for localization.
1027 *
1028 * @return array Recommended replacement variables.
1029 */
1030 private function get_recommended_replace_vars() {
1031 $recommended_replace_vars = new WPSEO_Admin_Recommended_Replace_Vars();
1032
1033 // What is recommended depends on the current context.
1034 $post_type = $recommended_replace_vars->determine_for_post( $this->get_metabox_post() );
1035
1036 return $recommended_replace_vars->get_recommended_replacevars_for( $post_type );
1037 }
1038
1039 /**
1040 * Gets the custom replace variables for custom taxonomies and fields.
1041 *
1042 * @param WP_Post $post The post to check for custom taxonomies and fields.
1043 *
1044 * @return array Array containing all the replacement variables.
1045 */
1046 private function get_custom_replace_vars( $post ) {
1047 return [
1048 'custom_fields' => $this->get_custom_fields_replace_vars( $post ),
1049 'custom_taxonomies' => $this->get_custom_taxonomies_replace_vars( $post ),
1050 ];
1051 }
1052
1053 /**
1054 * Gets the custom replace variables for custom taxonomies.
1055 *
1056 * @param WP_Post $post The post to check for custom taxonomies.
1057 *
1058 * @return array Array containing all the replacement variables.
1059 */
1060 private function get_custom_taxonomies_replace_vars( $post ) {
1061 $taxonomies = get_object_taxonomies( $post, 'objects' );
1062 $custom_replace_vars = [];
1063
1064 foreach ( $taxonomies as $taxonomy_name => $taxonomy ) {
1065
1066 if ( is_string( $taxonomy ) ) { // If attachment, see https://core.trac.wordpress.org/ticket/37368 .
1067 $taxonomy_name = $taxonomy;
1068 $taxonomy = get_taxonomy( $taxonomy_name );
1069 }
1070
1071 if ( $taxonomy->_builtin && $taxonomy->public ) {
1072 continue;
1073 }
1074
1075 $custom_replace_vars[ $taxonomy_name ] = [
1076 'name' => $taxonomy->name,
1077 'description' => $taxonomy->description,
1078 ];
1079 }
1080
1081 return $custom_replace_vars;
1082 }
1083
1084 /**
1085 * Gets the custom replace variables for custom fields.
1086 *
1087 * @param WP_Post $post The post to check for custom fields.
1088 *
1089 * @return array Array containing all the replacement variables.
1090 */
1091 private function get_custom_fields_replace_vars( $post ) {
1092 $custom_replace_vars = [];
1093
1094 // If no post object is passed, return the empty custom_replace_vars array.
1095 if ( ! is_object( $post ) ) {
1096 return $custom_replace_vars;
1097 }
1098
1099 $custom_fields = get_post_custom( $post->ID );
1100
1101 // If $custom_fields is an empty string or generally not an array, return early.
1102 if ( ! is_array( $custom_fields ) ) {
1103 return $custom_replace_vars;
1104 }
1105
1106 foreach ( $custom_fields as $custom_field_name => $custom_field ) {
1107 // Skip private custom fields.
1108 if ( substr( $custom_field_name, 0, 1 ) === '_' ) {
1109 continue;
1110 }
1111
1112 // Skip custom field values that are serialized.
1113 if ( is_serialized( $custom_field[0] ) ) {
1114 continue;
1115 }
1116
1117 $custom_replace_vars[ $custom_field_name ] = $custom_field[0];
1118 }
1119
1120 return $custom_replace_vars;
1121 }
1122
1123 /**
1124 * Checks if the page is the post overview page.
1125 *
1126 * @param string $page The page to check for the post overview page.
1127 *
1128 * @return bool Whether or not the given page is the post overview page.
1129 */
1130 public static function is_post_overview( $page ) {
1131 return $page === 'edit.php';
1132 }
1133
1134 /**
1135 * Checks if the page is the post edit page.
1136 *
1137 * @param string $page The page to check for the post edit page.
1138 *
1139 * @return bool Whether or not the given page is the post edit page.
1140 */
1141 public static function is_post_edit( $page ) {
1142 return $page === 'post.php'
1143 || $page === 'post-new.php';
1144 }
1145
1146 /**
1147 * Retrieves the product title.
1148 *
1149 * @return string The product title.
1150 */
1151 protected function get_product_title() {
1152 return YoastSEO()->helpers->product->get_product_name();
1153 }
1154 }
1155