PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.2
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.2
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
wordpress-seo / admin / metabox / class-metabox.php

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

1,200 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 $screen = WP_Screen::get();
362 $is_block_editor = $screen && $screen->is_block_editor();
363 if ( $is_block_editor && $this->get_metabox_post()->post_type === 'post' ) {
364 /**
365 * Filter: 'wpseo_enable_ai_content_planner_inline_banner' - Allows hiding the AI Content Planner inline banner site-wide.
366 *
367 * Returning false stops the hidden meta inputs from being rendered, which the editor JS treats as "banner disabled".
368 *
369 * @param bool $enabled Whether the inline banner should be available in the editor. Default true.
370 */
371 if ( apply_filters( 'wpseo_enable_ai_content_planner_inline_banner', true ) ) {
372 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output escaped in class.
373 echo new Meta_Fields_Presenter( $this->get_metabox_post(), 'content_planner' );
374 }
375 }
376
377 /**
378 * Filter: 'wpseo_content_meta_section_content' - Allow filtering the metabox content before outputting.
379 *
380 * @param string $post_content The metabox content string.
381 */
382 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output should be escaped in the filter.
383 echo apply_filters( 'wpseo_content_meta_section_content', '' );
384 }
385
386 /**
387 * Renders the metabox tabs.
388 *
389 * @return void
390 */
391 protected function render_tabs() {
392 echo '<div class="wpseo-metabox-content">';
393 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Reason: $this->get_product_title() returns a hard-coded string.
394 printf( '<div class="wpseo-metabox-menu"><ul role="tablist" class="yoast-aria-tabs" aria-label="%s">', $this->get_product_title() );
395
396 $tabs = $this->get_tabs();
397
398 foreach ( $tabs as $tab ) {
399 if ( $tab->name === 'premium' ) {
400 continue;
401 }
402
403 $tab->display_link();
404 }
405
406 echo '</ul></div>';
407
408 foreach ( $tabs as $tab ) {
409 $tab->display_content();
410 }
411
412 echo '</div>';
413 }
414
415 /**
416 * Returns the relevant metabox tabs for the current view.
417 *
418 * @return WPSEO_Metabox_Section[]
419 */
420 private function get_tabs() {
421 $tabs = [];
422
423 $label = __( 'SEO', 'wordpress-seo' );
424 if ( $this->seo_analysis->is_enabled() ) {
425 $label = '<span class="wpseo-score-icon-container" id="wpseo-seo-score-icon"></span>' . $label;
426 }
427 $tabs[] = new WPSEO_Metabox_Section_React( 'content', $label );
428
429 if ( $this->readability_analysis->is_enabled() ) {
430 $tabs[] = new WPSEO_Metabox_Section_Readability();
431 }
432
433 if ( $this->inclusive_language_analysis->is_enabled() ) {
434 $tabs[] = new WPSEO_Metabox_Section_Inclusive_Language();
435 }
436
437 if ( $this->is_advanced_metadata_enabled ) {
438 $tabs[] = new WPSEO_Metabox_Section_React(
439 'schema',
440 '<span class="wpseo-schema-icon"></span>' . __( 'Schema', 'wordpress-seo' ),
441 '',
442 );
443 }
444
445 if ( $this->social_is_enabled ) {
446 $tabs[] = new WPSEO_Metabox_Section_React(
447 'social',
448 '<span class="dashicons dashicons-share"></span>' . __( 'Social', 'wordpress-seo' ),
449 '',
450 [
451 'html_after' => '<div id="wpseo-section-social"></div>',
452 ],
453 );
454 }
455
456 $tabs = array_merge( $tabs, $this->get_additional_tabs() );
457
458 return $tabs;
459 }
460
461 /**
462 * Returns the metabox tabs that have been added by other plugins.
463 *
464 * @return WPSEO_Metabox_Section_Additional[]
465 */
466 protected function get_additional_tabs() {
467 $tabs = [];
468
469 /**
470 * Private filter: 'yoast_free_additional_metabox_sections'.
471 *
472 * Meant for internal use only. Allows adding additional tabs to the Yoast SEO metabox.
473 *
474 * @since 11.9
475 *
476 * @param array[] $tabs {
477 * An array of arrays with tab specifications.
478 *
479 * @type array $tab {
480 * A tab specification.
481 *
482 * @type string $name The name of the tab. Used in the HTML IDs, href and aria properties.
483 * @type string $link_content The content of the tab link.
484 * @type string $content The content of the tab.
485 * @type array $options {
486 * Optional. Extra options.
487 *
488 * @type string $link_class Optional. The class for the tab link.
489 * @type string $link_aria_label Optional. The aria label of the tab link.
490 * }
491 * }
492 * }
493 */
494 $requested_tabs = apply_filters( 'yoast_free_additional_metabox_sections', [] );
495
496 foreach ( $requested_tabs as $tab ) {
497 if ( is_array( $tab ) && array_key_exists( 'name', $tab ) && array_key_exists( 'link_content', $tab ) && array_key_exists( 'content', $tab ) ) {
498 $options = array_key_exists( 'options', $tab ) ? $tab['options'] : [];
499 $tabs[] = new WPSEO_Metabox_Section_Additional(
500 $tab['name'],
501 $tab['link_content'],
502 $tab['content'],
503 $options,
504 );
505 }
506 }
507
508 return $tabs;
509 }
510
511 /**
512 * Adds a line in the meta box.
513 *
514 * @deprecated 23.5
515 * @codeCoverageIgnore
516 *
517 * @param string[] $meta_field_def Contains the vars based on which output is generated.
518 * @param string $key Internal key (without prefix).
519 *
520 * @return string
521 */
522 public function do_meta_box( $meta_field_def, $key = '' ) {
523 _deprecated_function( __METHOD__, 'Yoast SEO 23.5' );
524
525 $content = '';
526 $esc_form_key = esc_attr( WPSEO_Meta::$form_prefix . $key );
527 $meta_value = WPSEO_Meta::get_value( $key, $this->get_metabox_post()->ID );
528
529 $class = '';
530 if ( isset( $meta_field_def['class'] ) && $meta_field_def['class'] !== '' ) {
531 $class = ' ' . $meta_field_def['class'];
532 }
533
534 $placeholder = '';
535 if ( isset( $meta_field_def['placeholder'] ) && $meta_field_def['placeholder'] !== '' ) {
536 $placeholder = $meta_field_def['placeholder'];
537 }
538
539 $aria_describedby = '';
540 $description = '';
541 if ( isset( $meta_field_def['description'] ) ) {
542 $aria_describedby = ' aria-describedby="' . $esc_form_key . '-desc"';
543 $description = '<p id="' . $esc_form_key . '-desc" class="yoast-metabox__description">' . $meta_field_def['description'] . '</p>';
544 }
545
546 // Add a hide_on_pages option that returns nothing when the field is rendered on a page.
547 if ( isset( $meta_field_def['hide_on_pages'] ) && $meta_field_def['hide_on_pages'] && get_post_type() === 'page' ) {
548 return '';
549 }
550
551 switch ( $meta_field_def['type'] ) {
552 case 'text':
553 $ac = '';
554 if ( isset( $meta_field_def['autocomplete'] ) && $meta_field_def['autocomplete'] === false ) {
555 $ac = 'autocomplete="off" ';
556 }
557 if ( $placeholder !== '' ) {
558 $placeholder = ' placeholder="' . esc_attr( $placeholder ) . '"';
559 }
560 $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 . '/>';
561 break;
562
563 case 'url':
564 if ( $placeholder !== '' ) {
565 $placeholder = ' placeholder="' . esc_attr( $placeholder ) . '"';
566 }
567 $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 . '/>';
568 break;
569
570 case 'textarea':
571 $rows = 3;
572 if ( isset( $meta_field_def['rows'] ) && $meta_field_def['rows'] > 0 ) {
573 $rows = $meta_field_def['rows'];
574 }
575 $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>';
576 break;
577
578 case 'hidden':
579 $default = '';
580 if ( isset( $meta_field_def['default'] ) ) {
581 $default = sprintf( ' data-default="%s"', esc_attr( $meta_field_def['default'] ) );
582 }
583 $content .= '<input type="hidden" id="' . $esc_form_key . '" name="' . $esc_form_key . '" value="' . esc_attr( $meta_value ) . '"' . $default . '/>' . "\n";
584 break;
585 case 'select':
586 if ( isset( $meta_field_def['options'] ) && is_array( $meta_field_def['options'] ) && $meta_field_def['options'] !== [] ) {
587 $content .= '<select name="' . $esc_form_key . '" id="' . $esc_form_key . '" class="yoast' . $class . '">';
588 foreach ( $meta_field_def['options'] as $val => $option ) {
589 $selected = selected( $meta_value, $val, false );
590 $content .= '<option ' . $selected . ' value="' . esc_attr( $val ) . '">' . esc_html( $option ) . '</option>';
591 }
592 unset( $val, $option, $selected );
593 $content .= '</select>';
594 }
595 break;
596
597 case 'multiselect':
598 if ( isset( $meta_field_def['options'] ) && is_array( $meta_field_def['options'] ) && $meta_field_def['options'] !== [] ) {
599
600 // Set $meta_value as $selected_arr.
601 $selected_arr = $meta_value;
602
603 // If the multiselect field is 'meta-robots-adv' we should explode on ,.
604 if ( $key === 'meta-robots-adv' ) {
605 $selected_arr = explode( ',', $meta_value );
606 }
607
608 if ( ! is_array( $selected_arr ) ) {
609 $selected_arr = (array) $selected_arr;
610 }
611
612 $options_count = count( $meta_field_def['options'] );
613
614 $content .= '<select multiple="multiple" size="' . esc_attr( $options_count ) . '" name="' . $esc_form_key . '[]" id="' . $esc_form_key . '" class="yoast' . $class . '"' . $aria_describedby . '>';
615 foreach ( $meta_field_def['options'] as $val => $option ) {
616 $selected = '';
617 if ( in_array( $val, $selected_arr, true ) ) {
618 $selected = ' selected="selected"';
619 }
620 $content .= '<option ' . $selected . ' value="' . esc_attr( $val ) . '">' . esc_html( $option ) . '</option>';
621 }
622 $content .= '</select>';
623 unset( $val, $option, $selected, $selected_arr, $options_count );
624 }
625 break;
626
627 case 'checkbox':
628 $checked = checked( $meta_value, 'on', false );
629 $expl = ( isset( $meta_field_def['expl'] ) ) ? esc_html( $meta_field_def['expl'] ) : '';
630 $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>';
631 unset( $checked, $expl );
632 break;
633
634 case 'radio':
635 if ( isset( $meta_field_def['options'] ) && is_array( $meta_field_def['options'] ) && $meta_field_def['options'] !== [] ) {
636 foreach ( $meta_field_def['options'] as $val => $option ) {
637 $checked = checked( $meta_value, $val, false );
638 $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> ';
639 }
640 unset( $val, $option, $checked );
641 }
642 break;
643 }
644
645 $html = '';
646 if ( $content === '' ) {
647 $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' );
648 }
649
650 if ( $content !== '' ) {
651
652 $title = esc_html( $meta_field_def['title'] );
653
654 // By default, use the field title as a label element.
655 $label = '<label for="' . $esc_form_key . '">' . $title . '</label>';
656
657 // Set the inline help and help panel, if any.
658 $help_button = '';
659 $help_panel = '';
660 if ( isset( $meta_field_def['help'] ) && $meta_field_def['help'] !== '' ) {
661 $help = new WPSEO_Admin_Help_Panel( $key, $meta_field_def['help-button'], $meta_field_def['help'] );
662 $help_button = $help->get_button_html();
663 $help_panel = $help->get_panel_html();
664 }
665
666 // If it's a set of radio buttons, output proper fieldset and legend.
667 if ( $meta_field_def['type'] === 'radio' ) {
668 return '<fieldset><legend>' . $title . '</legend>' . $help_button . $help_panel . $content . $description . '</fieldset>';
669 }
670
671 // If it's a single checkbox, ignore the title.
672 if ( $meta_field_def['type'] === 'checkbox' ) {
673 $label = '';
674 }
675
676 // Other meta box content or form fields.
677 if ( $meta_field_def['type'] === 'hidden' ) {
678 $html = $content;
679 }
680 else {
681 $html = $label . $description . $help_button . $help_panel . $content;
682 }
683 }
684
685 return $html;
686 }
687
688 /**
689 * Saves the WP SEO metadata for posts.
690 *
691 * {@internal $_POST parameters are validated via sanitize_post_meta().}}
692 *
693 * @param int $post_id Post ID.
694 *
695 * @return bool|void Boolean false if invalid save post request.
696 */
697 public function save_postdata( $post_id ) {
698 // Bail if this is a multisite installation and the site has been switched.
699 if ( is_multisite() && ms_is_switched() ) {
700 return false;
701 }
702
703 if ( $post_id === null ) {
704 return false;
705 }
706
707 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized in wp_verify_none.
708 if ( ! isset( $_POST['yoast_free_metabox_nonce'] ) || ! wp_verify_nonce( wp_unslash( $_POST['yoast_free_metabox_nonce'] ), 'yoast_free_metabox' ) ) {
709 return false;
710 }
711
712 if ( wp_is_post_revision( $post_id ) ) {
713 $post_id = wp_is_post_revision( $post_id );
714 }
715
716 /**
717 * Determine we're not accidentally updating a different post.
718 * We can't use filter_input here as the ID isn't available at this point, other than in the $_POST data.
719 */
720 if ( ! isset( $_POST['ID'] ) || $post_id !== (int) $_POST['ID'] ) {
721 return false;
722 }
723
724 clean_post_cache( $post_id );
725 $post = get_post( $post_id );
726
727 if ( ! is_object( $post ) ) {
728 // Non-existent post.
729 return false;
730 }
731
732 do_action( 'wpseo_save_compare_data', $post );
733
734 $social_fields = [];
735 if ( $this->social_is_enabled ) {
736 $social_fields = WPSEO_Meta::get_meta_field_defs( 'social' );
737 }
738
739 $meta_boxes = apply_filters( 'wpseo_save_metaboxes', [] );
740 $meta_boxes = array_merge(
741 $meta_boxes,
742 WPSEO_Meta::get_meta_field_defs( 'general', $post->post_type ),
743 WPSEO_Meta::get_meta_field_defs( 'advanced' ),
744 $social_fields,
745 WPSEO_Meta::get_meta_field_defs( 'schema', $post->post_type ),
746 );
747
748 // 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.
749 if ( $post->post_type === 'post' ) {
750 $meta_boxes = array_merge( $meta_boxes, WPSEO_Meta::get_meta_field_defs( 'content_planner' ) );
751 }
752
753 foreach ( $meta_boxes as $key => $meta_box ) {
754
755 // If analysis is disabled remove that analysis score value from the DB.
756 if ( $this->is_meta_value_disabled( $key ) ) {
757 WPSEO_Meta::delete( $key, $post_id );
758 continue;
759 }
760
761 $data = null;
762 $field_name = WPSEO_Meta::$form_prefix . $key;
763
764 if ( $meta_box['type'] === 'checkbox' ) {
765 $data = isset( $_POST[ $field_name ] ) ? 'on' : 'off';
766 }
767 else {
768 if ( isset( $_POST[ $field_name ] ) ) {
769 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- We're preparing to do just that.
770 $data = wp_unslash( $_POST[ $field_name ] );
771
772 // For multi-select.
773 if ( is_array( $data ) ) {
774 $data = array_map( [ 'WPSEO_Utils', 'sanitize_text_field' ], $data );
775 }
776
777 if ( is_string( $data ) ) {
778 $data = ( $key !== 'canonical' ) ? WPSEO_Utils::sanitize_text_field( $data ) : WPSEO_Utils::sanitize_url( $data );
779 }
780 }
781
782 // Reset options when no entry is present with multiselect - only applies to `meta-robots-adv` currently.
783 if ( ! isset( $_POST[ $field_name ] ) && ( $meta_box['type'] === 'multiselect' ) ) {
784 $data = [];
785 }
786 }
787
788 if ( $data !== null ) {
789 WPSEO_Meta::set_value( $key, $data, $post_id );
790 }
791 }
792
793 do_action( 'wpseo_saved_postdata' );
794 }
795
796 /**
797 * Determines if the given meta value key is disabled.
798 *
799 * @param string $key The key of the meta value.
800 *
801 * @return bool Whether the given meta value key is disabled.
802 */
803 public function is_meta_value_disabled( $key ) {
804 if ( $key === 'linkdex' && ! $this->seo_analysis->is_enabled() ) {
805 return true;
806 }
807
808 if ( $key === 'content_score' && ! $this->readability_analysis->is_enabled() ) {
809 return true;
810 }
811
812 if ( $key === 'inclusive_language_score' && ! $this->inclusive_language_analysis->is_enabled() ) {
813 return true;
814 }
815
816 return false;
817 }
818
819 /**
820 * Enqueues all the needed JS and CSS.
821 *
822 * @todo [JRF => whomever] Create css/metabox-mp6.css file and add it to the below allowed colors array when done.
823 *
824 * @return void
825 */
826 public function enqueue() {
827 global $pagenow;
828
829 if ( $this->readability_analysis->is_enabled() ) {
830 $this->editor = new WPSEO_Metabox_Editor();
831 $this->editor->register_hooks();
832 }
833
834 $asset_manager = new WPSEO_Admin_Asset_Manager();
835
836 if ( self::is_post_overview( $pagenow ) ) {
837 return;
838 }
839
840 /* Filter 'wpseo_always_register_metaboxes_on_admin' documented in wpseo-main.php */
841 if ( ( self::is_post_edit( $pagenow ) === false && apply_filters( 'wpseo_always_register_metaboxes_on_admin', false ) === false ) || $this->display_metabox() === false ) {
842 return;
843 }
844
845 $post_id = get_queried_object_id();
846 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information.
847 if ( empty( $post_id ) && isset( $_GET['post'] ) && is_string( $_GET['post'] ) ) {
848 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information.
849 $post_id = sanitize_text_field( wp_unslash( $_GET['post'] ) );
850 }
851
852 if ( $post_id !== 0 ) {
853 // Enqueue files needed for upload functionality.
854 wp_enqueue_media( [ 'post' => $post_id ] );
855 }
856
857 $asset_manager->enqueue_style( 'metabox-css' );
858 if ( $this->readability_analysis->is_enabled() ) {
859 $asset_manager->enqueue_style( 'scoring' );
860 }
861 $asset_manager->enqueue_style( 'monorepo' );
862 $asset_manager->enqueue_style( 'ai-generator' );
863 $asset_manager->enqueue_style( 'ai-fix-assessments' );
864
865 $is_block_editor = WP_Screen::get()->is_block_editor();
866 $post_edit_handle = 'post-edit';
867 if ( ! $is_block_editor ) {
868 $post_edit_handle = 'post-edit-classic';
869 }
870 $asset_manager->enqueue_script( $post_edit_handle );
871 $asset_manager->enqueue_style( 'admin-css' );
872
873 /**
874 * Removes the emoji script as it is incompatible with both React and any
875 * contenteditable fields.
876 */
877 remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
878
879 $asset_manager->localize_script( $post_edit_handle, 'wpseoAdminL10n', WPSEO_Utils::get_admin_l10n() );
880
881 $plugins_script_data = [
882 'replaceVars' => [
883 'replace_vars' => $this->get_replace_vars(),
884 'hidden_replace_vars' => $this->get_hidden_replace_vars(),
885 'recommended_replace_vars' => $this->get_recommended_replace_vars(),
886 'scope' => $this->determine_scope(),
887 'has_taxonomies' => $this->current_post_type_has_taxonomies(),
888 ],
889 'shortcodes' => [
890 'wpseo_shortcode_tags' => $this->get_valid_shortcode_tags(),
891 'wpseo_filter_shortcodes_nonce' => wp_create_nonce( 'wpseo-filter-shortcodes' ),
892 ],
893 ];
894
895 $worker_script_data = [
896 'url' => YoastSEO()->helpers->asset->get_asset_url( 'yoast-seo-analysis-worker' ),
897 'dependencies' => YoastSEO()->helpers->asset->get_dependency_urls_by_handle( 'yoast-seo-analysis-worker' ),
898 'keywords_assessment_url' => YoastSEO()->helpers->asset->get_asset_url( 'yoast-seo-used-keywords-assessment' ),
899 'log_level' => WPSEO_Utils::get_analysis_worker_log_level(),
900 ];
901
902 $page_on_front = (int) get_option( 'page_on_front' );
903 $homepage_is_page = get_option( 'show_on_front' ) === 'page';
904 $is_front_page = $homepage_is_page && $page_on_front === (int) $post_id;
905
906 $script_data = [
907 'metabox' => $this->get_metabox_script_data(),
908 'isPost' => true,
909 'isBlockEditor' => $is_block_editor,
910 'postId' => $post_id,
911 'postStatus' => get_post_status( $post_id ),
912 'postType' => get_post_type( $post_id ),
913 'isPage' => get_post_type( $post_id ) === 'page',
914 'usedKeywordsNonce' => wp_create_nonce( 'wpseo-keyword-usage-and-post-types' ),
915 'analysis' => [
916 'plugins' => $plugins_script_data,
917 'worker' => $worker_script_data,
918 ],
919 'isFrontPage' => $is_front_page,
920 ];
921
922 /**
923 * The website information repository.
924 *
925 * @var Website_Information_Repository $repo
926 */
927 $repo = YoastSEO()->classes->get( Website_Information_Repository::class );
928 $site_information = $repo->get_post_site_information();
929 $site_information->set_permalink( $this->get_permalink() );
930 $script_data = array_merge_recursive( $site_information->get_legacy_site_information(), $script_data );
931
932 if ( ! $is_block_editor && post_type_supports( get_post_type(), 'thumbnail' ) ) {
933 $asset_manager->enqueue_style( 'featured-image' );
934 }
935
936 $asset_manager->localize_script( $post_edit_handle, 'wpseoScriptData', $script_data );
937 }
938
939 /**
940 * Returns post in metabox context.
941 *
942 * @return WP_Post|array<string|int|bool>
943 */
944 protected function get_metabox_post() {
945 if ( $this->post !== null ) {
946 return $this->post;
947 }
948
949 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information.
950 if ( isset( $_GET['post'] ) && is_string( $_GET['post'] ) ) {
951 // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Reason: We are not processing form information, Sanitization happens in the validate_int function.
952 $post_id = (int) WPSEO_Utils::validate_int( wp_unslash( $_GET['post'] ) );
953
954 $this->post = get_post( $post_id );
955
956 return $this->post;
957 }
958
959 if ( isset( $GLOBALS['post'] ) ) {
960 $this->post = $GLOBALS['post'];
961
962 return $this->post;
963 }
964
965 return [];
966 }
967
968 /**
969 * Returns an array with shortcode tags for all registered shortcodes.
970 *
971 * @return string[]
972 */
973 private function get_valid_shortcode_tags() {
974 $shortcode_tags = [];
975
976 foreach ( $GLOBALS['shortcode_tags'] as $tag => $description ) {
977 $shortcode_tags[] = $tag;
978 }
979
980 return $shortcode_tags;
981 }
982
983 /**
984 * Prepares the replace vars for localization.
985 *
986 * @return string[] Replace vars.
987 */
988 private function get_replace_vars() {
989 $cached_replacement_vars = [];
990
991 $vars_to_cache = [
992 'date',
993 'id',
994 'sitename',
995 'sitedesc',
996 'sep',
997 'page',
998 'currentdate',
999 'currentyear',
1000 'currentmonth',
1001 'currentday',
1002 'post_year',
1003 'post_month',
1004 'post_day',
1005 'name',
1006 'author_first_name',
1007 'author_last_name',
1008 'permalink',
1009 'post_content',
1010 'category_title',
1011 'tag',
1012 'category',
1013 ];
1014
1015 foreach ( $vars_to_cache as $var ) {
1016 $cached_replacement_vars[ $var ] = wpseo_replace_vars( '%%' . $var . '%%', $this->get_metabox_post() );
1017 }
1018
1019 // Merge custom replace variables with the WordPress ones.
1020 return array_merge( $cached_replacement_vars, $this->get_custom_replace_vars( $this->get_metabox_post() ) );
1021 }
1022
1023 /**
1024 * Returns the list of replace vars that should be hidden inside the editor.
1025 *
1026 * @return string[] The hidden replace vars.
1027 */
1028 protected function get_hidden_replace_vars() {
1029 return ( new WPSEO_Replace_Vars() )->get_hidden_replace_vars();
1030 }
1031
1032 /**
1033 * Prepares the recommended replace vars for localization.
1034 *
1035 * @return array<string[]> Recommended replacement variables.
1036 */
1037 private function get_recommended_replace_vars() {
1038 $recommended_replace_vars = new WPSEO_Admin_Recommended_Replace_Vars();
1039
1040 // What is recommended depends on the current context.
1041 $post_type = $recommended_replace_vars->determine_for_post( $this->get_metabox_post() );
1042
1043 return $recommended_replace_vars->get_recommended_replacevars_for( $post_type );
1044 }
1045
1046 /**
1047 * Gets the custom replace variables for custom taxonomies and fields.
1048 *
1049 * @param WP_Post $post The post to check for custom taxonomies and fields.
1050 *
1051 * @return array<string[]> Array containing all the replacement variables.
1052 */
1053 private function get_custom_replace_vars( $post ) {
1054 return [
1055 'custom_fields' => $this->get_custom_fields_replace_vars( $post ),
1056 'custom_taxonomies' => $this->get_custom_taxonomies_replace_vars( $post ),
1057 ];
1058 }
1059
1060 /**
1061 * Gets the custom replace variables for custom taxonomies.
1062 *
1063 * @param WP_Post $post The post to check for custom taxonomies.
1064 *
1065 * @return array<string[]> Array containing all the replacement variables.
1066 */
1067 private function get_custom_taxonomies_replace_vars( $post ) {
1068 $taxonomies = get_object_taxonomies( $post, 'objects' );
1069 $custom_replace_vars = [];
1070
1071 foreach ( $taxonomies as $taxonomy_name => $taxonomy ) {
1072
1073 if ( is_string( $taxonomy ) ) { // If attachment, see https://core.trac.wordpress.org/ticket/37368 .
1074 $taxonomy_name = $taxonomy;
1075 $taxonomy = get_taxonomy( $taxonomy_name );
1076 }
1077
1078 if ( $taxonomy->_builtin && $taxonomy->public ) {
1079 continue;
1080 }
1081
1082 $custom_replace_vars[ $taxonomy_name ] = [
1083 'name' => $taxonomy->name,
1084 'description' => $taxonomy->description,
1085 ];
1086 }
1087
1088 return $custom_replace_vars;
1089 }
1090
1091 /**
1092 * Gets the custom replace variables for custom fields.
1093 *
1094 * @param WP_Post $post The post to check for custom fields.
1095 *
1096 * @return array<string[]> Array containing all the replacement variables.
1097 */
1098 private function get_custom_fields_replace_vars( $post ) {
1099 $custom_replace_vars = [];
1100
1101 // If no post object is passed, return the empty custom_replace_vars array.
1102 if ( ! is_object( $post ) ) {
1103 return $custom_replace_vars;
1104 }
1105
1106 $custom_fields = get_post_custom( $post->ID );
1107
1108 // If $custom_fields is an empty string or generally not an array, return early.
1109 if ( ! is_array( $custom_fields ) ) {
1110 return $custom_replace_vars;
1111 }
1112
1113 $meta = YoastSEO()->meta->for_post( $post->ID );
1114
1115 if ( ! $meta ) {
1116 return $custom_replace_vars;
1117 }
1118
1119 // Simply concatenate all fields containing replace vars so we can handle them all with a single regex find.
1120 $replace_vars_fields = implode(
1121 ' ',
1122 [
1123 $meta->presentation->title,
1124 $meta->presentation->meta_description,
1125 ],
1126 );
1127
1128 preg_match_all( '/%%cf_([A-Za-z0-9_]+)%%/', $replace_vars_fields, $matches );
1129 $fields_to_include = $matches[1];
1130 foreach ( $custom_fields as $custom_field_name => $custom_field ) {
1131 // Skip private custom fields.
1132 if ( substr( $custom_field_name, 0, 1 ) === '_' ) {
1133 continue;
1134 }
1135
1136 // Skip custom fields that are not used, new ones will be fetched dynamically.
1137 if ( ! in_array( $custom_field_name, $fields_to_include, true ) ) {
1138 continue;
1139 }
1140
1141 // Skip custom field values that are serialized.
1142 if ( is_serialized( $custom_field[0] ) ) {
1143 continue;
1144 }
1145
1146 $custom_replace_vars[ $custom_field_name ] = $custom_field[0];
1147 }
1148
1149 return $custom_replace_vars;
1150 }
1151
1152 /**
1153 * Checks if the page is the post overview page.
1154 *
1155 * @param string $page The page to check for the post overview page.
1156 *
1157 * @return bool Whether or not the given page is the post overview page.
1158 */
1159 public static function is_post_overview( $page ) {
1160 return $page === 'edit.php';
1161 }
1162
1163 /**
1164 * Checks if the page is the post edit page.
1165 *
1166 * @param string $page The page to check for the post edit page.
1167 *
1168 * @return bool Whether or not the given page is the post edit page.
1169 */
1170 public static function is_post_edit( $page ) {
1171 return $page === 'post.php'
1172 || $page === 'post-new.php';
1173 }
1174
1175 /**
1176 * Retrieves the product title.
1177 *
1178 * @return string The product title.
1179 */
1180 protected function get_product_title() {
1181 return YoastSEO()->helpers->product->get_product_name();
1182 }
1183
1184 /**
1185 * Gets the permalink.
1186 *
1187 * @return string
1188 */
1189 protected function get_permalink() {
1190 $permalink = '';
1191
1192 if ( is_object( $this->get_metabox_post() ) ) {
1193 $permalink = get_sample_permalink( $this->get_metabox_post()->ID );
1194 $permalink = $permalink[0];
1195 }
1196
1197 return $permalink;
1198 }
1199 }
1200