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

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