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
← All changes | inc/class-wpseo-meta.php +158 -106 18.4 → trunk View file →
@@ -68,10 +68,8 @@
68 68 /**
69 69 * Meta box field definitions for the meta box form.
70 70 *
71 71 * {@internal
72 - * - Titles, help texts, description text and option labels are added via a translate_meta_boxes() method
73 - * in the relevant child classes (WPSEO_Metabox and WPSEO_Social_admin) as they are only needed there.
74 72 * - Beware: even though the meta keys are divided into subsets, they still have to be uniquely named!}}
75 73 *
76 74 * @var array
77 75 * Array format:
@@ -76,9 +74,8 @@
76 74 * @var array
77 75 * Array format:
78 76 * (required) 'type' => (string) field type. i.e. text / textarea / checkbox /
79 77 * radio / select / multiselect / upload etc.
80 - * (required) 'title' => (string) table row title.
81 78 * (recommended) 'default_value' => (string|array) default value for the field.
82 79 * IMPORTANT:
83 80 * - if the field has options, the default has to be the
84 81 * key of one of the options.
@@ -93,132 +90,128 @@
93 90 * value = (string) text label for the option.
94 91 * (optional) 'autocomplete' => (bool) whether autocomplete is on for text fields,
95 92 * defaults to true.
96 93 * (optional) 'class' => (string) classname(s) to add to the actual <input> tag.
97 - * (optional) 'description' => (string) description to show underneath the field.
98 - * (optional) 'expl' => (string) label for a checkbox.
99 - * (optional) 'help' => (string) help text to show on mouse over ? image.
100 94 * (optional) 'rows' => (int) number of rows for a textarea, defaults to 3.
101 - * (optional) 'placeholder' => (string) Currently only used by add-on plugins.
102 95 * (optional) 'serialized' => (bool) whether the value is expected to be serialized,
103 96 * i.e. an array or object, defaults to false.
104 97 * Currently only used by add-on plugins.
105 98 */
106 99 public static $meta_fields = [
107 - 'general' => [
100 + 'general' => [
108 101 'focuskw' => [
109 - 'type' => 'hidden',
110 - 'title' => '',
102 + 'type' => 'hidden',
103 + 'title' => '',
104 + 'show_in_rest' => true,
105 + 'single' => true,
111 106 ],
112 107 'title' => [
113 108 'type' => 'hidden',
114 - 'title' => '', // Translation added later.
115 109 'default_value' => '',
116 - 'description' => '', // Translation added later.
117 - 'help' => '', // Translation added later.
110 + 'show_in_rest' => true,
111 + 'single' => true,
118 112 ],
119 113 'metadesc' => [
120 114 'type' => 'hidden',
121 - 'title' => '', // Translation added later.
122 115 'default_value' => '',
123 116 'class' => 'metadesc',
124 117 'rows' => 2,
125 - 'description' => '', // Translation added later.
126 - 'help' => '', // Translation added later.
118 + 'show_in_rest' => true,
119 + 'single' => true,
127 120 ],
128 121 'linkdex' => [
129 122 'type' => 'hidden',
130 - 'title' => 'linkdex',
131 123 'default_value' => '0',
132 - 'description' => '',
133 124 ],
134 125 'content_score' => [
135 126 'type' => 'hidden',
136 - 'title' => 'content_score',
137 127 'default_value' => '0',
138 - 'description' => '',
139 128 ],
129 + 'inclusive_language_score' => [
130 + 'type' => 'hidden',
131 + 'default_value' => '0',
132 + ],
133 + 'seo_title_score' => [
134 + 'type' => 'hidden',
135 + 'default_value' => '0',
136 + ],
137 + 'meta_description_score' => [
138 + 'type' => 'hidden',
139 + 'default_value' => '0',
140 + ],
140 141 'is_cornerstone' => [
141 142 'type' => 'hidden',
142 - 'title' => 'is_cornerstone',
143 143 'default_value' => 'false',
144 - 'description' => '',
145 144 ],
146 145 ],
147 - 'advanced' => [
146 + 'advanced' => [
148 147 'meta-robots-noindex' => [
149 148 'type' => 'hidden',
150 - 'title' => '', // Translation added later.
151 149 'default_value' => '0', // = post-type default.
152 150 'options' => [
153 - '0' => '', // Post type default - translation added later.
154 - '2' => '', // Index - translation added later.
155 - '1' => '', // No-index - translation added later.
151 + '0' => '', // Post type default.
152 + '2' => '', // Index.
153 + '1' => '', // No-index.
156 154 ],
157 155 ],
158 156 'meta-robots-nofollow' => [
159 157 'type' => 'hidden',
160 - 'title' => '', // Translation added later.
161 158 'default_value' => '0', // = follow.
162 159 'options' => [
163 - '0' => '', // Follow - translation added later.
164 - '1' => '', // No-follow - translation added later.
160 + '0' => '', // Follow.
161 + '1' => '', // No-follow.
165 162 ],
166 163 ],
167 164 'meta-robots-adv' => [
168 165 'type' => 'hidden',
169 - 'title' => '', // Translation added later.
170 166 'default_value' => '',
171 - 'description' => '', // Translation added later.
172 167 'options' => [
173 - 'noimageindex' => '', // Translation added later.
174 - 'noarchive' => '', // Translation added later.
175 - 'nosnippet' => '', // Translation added later.
168 + 'noimageindex' => '',
169 + 'noarchive' => '',
170 + 'nosnippet' => '',
176 171 ],
177 172 ],
178 173 'bctitle' => [
179 174 'type' => 'hidden',
180 - 'title' => '', // Translation added later.
181 175 'default_value' => '',
182 - 'description' => '', // Translation added later.
183 176 ],
184 177 'canonical' => [
185 178 'type' => 'hidden',
186 - 'title' => '', // Translation added later.
187 179 'default_value' => '',
188 - 'description' => '', // Translation added later.
189 180 ],
190 181 'redirect' => [
191 182 'type' => 'url',
192 - 'title' => '', // Translation added later.
193 183 'default_value' => '',
194 - 'description' => '', // Translation added later.
195 184 ],
196 185 ],
197 - 'social' => [],
198 - 'schema' => [
186 + 'social' => [],
187 + 'schema' => [
199 188 'schema_page_type' => [
200 189 'type' => 'hidden',
201 - 'title' => '',
202 190 'options' => Schema_Types::PAGE_TYPES,
203 191 ],
204 192 'schema_article_type' => [
205 193 'type' => 'hidden',
206 - 'title' => '',
207 194 'hide_on_pages' => true,
208 195 'options' => Schema_Types::ARTICLE_TYPES,
209 196 ],
210 197 ],
211 198 /* Fields we should validate & save, but not show on any form. */
212 - 'non_form' => [
199 + 'non_form' => [
213 200 'linkdex' => [
214 201 'type' => null,
215 202 'default_value' => '0',
216 203 ],
217 - 'zapier_trigger_sent' => [
218 - 'type' => null,
204 + ],
205 + 'content_planner' => [
206 + 'is_content_planner_banner_rendered' => [
207 + 'type' => 'hidden',
219 208 'default_value' => '0',
220 209 ],
210 + 'is_content_planner_banner_dismissed' => [
211 + 'type' => 'hidden',
212 + 'default_value' => '0',
213 + ],
221 214 ],
222 215 ];
223 216
224 217 /**
@@ -268,15 +261,13 @@
268 261 * @return void
269 262 */
270 263 public static function init() {
271 264 foreach ( self::$social_networks as $option => $network ) {
272 - if ( WPSEO_Options::get( $option, false ) === true ) {
265 + if ( WPSEO_Options::get( $option, false, [ 'wpseo_social' ] ) === true ) {
273 266 foreach ( self::$social_fields as $box => $type ) {
274 267 self::$meta_fields['social'][ $network . '-' . $box ] = [
275 268 'type' => $type,
276 - 'title' => '', // Translation added later.
277 269 'default_value' => '',
278 - 'description' => '', // Translation added later.
279 270 ];
280 271 }
281 272 }
282 273 }
@@ -294,14 +285,33 @@
294 285
295 286 foreach ( self::$meta_fields as $subset => $field_group ) {
296 287 foreach ( $field_group as $key => $field_def ) {
297 288
289 + // Register for all post types: sanitise callback only, REST disabled.
298 290 register_meta(
299 291 'post',
300 292 self::$meta_prefix . $key,
301 - [ 'sanitize_callback' => [ __CLASS__, 'sanitize_post_meta' ] ]
293 + [ 'sanitize_callback' => [ self::class, 'sanitize_post_meta' ] ],
302 294 );
303 295
296 + // Re-register for the 'post' subtype with REST exposure and auth callback when show_in_rest is enabled.
297 + if ( ! empty( $field_def['show_in_rest'] ) ) {
298 + register_meta(
299 + 'post',
300 + self::$meta_prefix . $key,
301 + [
302 + 'show_in_rest' => true,
303 + 'single' => ( $field_def['single'] ?? false ),
304 + 'type' => 'string',
305 + 'object_subtype' => 'post',
306 + 'sanitize_callback' => [ self::class, 'sanitize_post_meta' ],
307 + 'auth_callback' => static function ( $allowed, $meta_key, $object_id ) {
308 + return current_user_can( 'edit_post', $object_id );
309 + },
310 + ],
311 + );
312 + }
313 +
304 314 // Set the $fields_index property for efficiency.
305 315 self::$fields_index[ self::$meta_prefix . $key ] = [
306 316 'subset' => $subset,
307 317 'key' => $key,
@@ -318,12 +328,18 @@
318 328 }
319 329 }
320 330 unset( $subset, $field_group, $key, $field_def );
321 331
332 + // Strip meta fields that have show_in_rest enabled from REST responses for users
333 + // without edit_post capability. register_meta's auth_callback only covers writes,
334 + // so read access must be restricted separately via this filter.
335 + // Register only for 'post' post type. Other post types don't expose these fields.
336 + add_filter( 'rest_prepare_post', [ self::class, 'hide_meta_from_unauthorized_rest_response' ], 10, 2 );
337 +
322 338 self::filter_schema_article_types();
323 339
324 - add_filter( 'update_post_metadata', [ __CLASS__, 'remove_meta_if_default' ], 10, 5 );
325 - add_filter( 'add_post_metadata', [ __CLASS__, 'dont_save_meta_if_default' ], 10, 4 );
340 + add_filter( 'update_post_metadata', [ self::class, 'remove_meta_if_default' ], 10, 5 );
341 + add_filter( 'add_post_metadata', [ self::class, 'dont_save_meta_if_default' ], 10, 4 );
326 342 }
327 343
328 344 /**
329 345 * Retrieve the meta box form field definitions for the given tab and post type.
@@ -364,22 +380,13 @@
364 380 if ( $post_type === '' ) {
365 381 return [];
366 382 }
367 383
368 - /* Adjust the no-index text strings based on the post type. */
369 - $post_type_object = get_post_type_object( $post_type );
370 -
371 - $field_defs['meta-robots-noindex']['title'] = sprintf( $field_defs['meta-robots-noindex']['title'], $post_type_object->labels->singular_name );
372 - $field_defs['meta-robots-noindex']['options']['0'] = sprintf( $field_defs['meta-robots-noindex']['options']['0'], ( ( WPSEO_Options::get( 'noindex-' . $post_type, false ) === true ) ? $field_defs['meta-robots-noindex']['options']['1'] : $field_defs['meta-robots-noindex']['options']['2'] ), $post_type_object->label );
373 - $field_defs['meta-robots-nofollow']['title'] = sprintf( $field_defs['meta-robots-nofollow']['title'], $post_type_object->labels->singular_name );
374 -
375 384 /* Don't show the breadcrumb title field if breadcrumbs aren't enabled. */
376 385 if ( WPSEO_Options::get( 'breadcrumbs-enable', false ) !== true && ! current_theme_supports( 'yoast-seo-breadcrumbs' ) ) {
377 386 unset( $field_defs['bctitle'] );
378 387 }
379 388
380 - global $post;
381 -
382 389 if ( empty( $post->ID ) || ( ! empty( $post->ID ) && self::get_value( 'redirect', $post->ID ) === '' ) ) {
383 390 unset( $field_defs['redirect'] );
384 391 }
385 392 break;
@@ -397,9 +404,9 @@
397 404
398 405 /** This filter is documented in inc/options/class-wpseo-option-titles.php */
399 406 $allowed_article_types = apply_filters( 'wpseo_schema_article_types', Schema_Types::ARTICLE_TYPES );
400 407
401 - if ( ! \array_key_exists( $default_schema_article_type, $allowed_article_types ) ) {
408 + if ( ! array_key_exists( $default_schema_article_type, $allowed_article_types ) ) {
402 409 $default_schema_article_type = WPSEO_Options::get_default( 'wpseo_titles', 'schema-article-type-' . $post_type );
403 410 }
404 411 $field_defs['schema_article_type']['default'] = $default_schema_article_type;
405 412 }
@@ -437,12 +444,20 @@
437 444 switch ( true ) {
438 445 case ( $meta_key === self::$meta_prefix . 'linkdex' ):
439 446 $int = WPSEO_Utils::validate_int( $meta_value );
440 447 if ( $int !== false && $int >= 0 ) {
441 - $clean = strval( $int ); // Convert to string to make sure default check works.
448 + $clean = (string) $int; // Convert to string to make sure default check works.
442 449 }
443 450 break;
444 451
452 + case ( in_array( $meta_key, [ self::$meta_prefix . 'seo_title_score', self::$meta_prefix . 'meta_description_score' ], true ) ):
453 + // Per-field scores are 0-100 percentages; out-of-range input keeps the "never scored" default.
454 + $int = WPSEO_Utils::validate_int( $meta_value );
455 + if ( $int !== false && $int >= 0 && $int <= 100 ) {
456 + $clean = (string) $int; // Convert to string to make sure default check works.
457 + }
458 + break;
459 +
445 460 case ( $field_def['type'] === 'checkbox' ):
446 461 // Only allow value if it's one of the predefined options.
447 462 if ( in_array( $meta_value, [ 'on', 'off' ], true ) ) {
448 463 $clean = $meta_value;
@@ -448,9 +463,8 @@
448 463 $clean = $meta_value;
449 464 }
450 465 break;
451 466
452 -
453 467 case ( $field_def['type'] === 'select' || $field_def['type'] === 'radio' ):
454 468 // Only allow value if it's one of the predefined options.
455 469 if ( isset( $field_def['options'][ $meta_value ] ) ) {
456 470 $clean = $meta_value;
@@ -456,14 +470,12 @@
456 470 $clean = $meta_value;
457 471 }
458 472 break;
459 473
460 -
461 474 case ( $field_def['type'] === 'hidden' && $meta_key === self::$meta_prefix . 'meta-robots-adv' ):
462 475 $clean = self::validate_meta_robots_adv( $meta_value );
463 476 break;
464 477
465 -
466 478 case ( $field_def['type'] === 'url' || $meta_key === self::$meta_prefix . 'canonical' ):
467 479 // Validate as url(-part).
468 480 $url = WPSEO_Utils::sanitize_url( $meta_value );
469 481 if ( $url !== '' ) {
@@ -470,9 +482,8 @@
470 482 $clean = $url;
471 483 }
472 484 break;
473 485
474 -
475 486 case ( $field_def['type'] === 'upload' && in_array( $meta_key, [ self::$meta_prefix . 'opengraph-image', self::$meta_prefix . 'twitter-image' ], true ) ):
476 487 // Validate as url.
477 488 $url = WPSEO_Utils::sanitize_url( $meta_value, [ 'http', 'https', 'ftp', 'ftps' ] );
478 489 if ( $url !== '' ) {
@@ -511,9 +522,8 @@
511 522 case ( $field_def['type'] === 'multiselect' ):
512 523 $clean = $meta_value;
513 524 break;
514 525
515 -
516 526 case ( $field_def['type'] === 'text' ):
517 527 default:
518 528 if ( is_string( $meta_value ) ) {
519 529 $clean = WPSEO_Utils::sanitize_text_field( trim( $meta_value ) );
@@ -765,9 +775,9 @@
765 775 )
766 776 ;",
767 777 $old_metakey,
768 778 $wpdb->esc_like( self::$meta_prefix . '%' ),
769 - self::$meta_prefix . 'linkdex'
779 + self::$meta_prefix . 'linkdex',
770 780 );
771 781 $oldies = $wpdb->get_results( $query );
772 782
773 783 if ( is_array( $oldies ) && $oldies !== [] ) {
@@ -817,9 +827,9 @@
817 827 )
818 828 ;",
819 829 self::$meta_prefix . 'meta-robots',
820 830 self::$meta_prefix . 'meta-robots-noindex',
821 - self::$meta_prefix . 'meta-robots-nofollow'
831 + self::$meta_prefix . 'meta-robots-nofollow',
822 832 );
823 833 $oldies = $wpdb->get_results( $query );
824 834
825 835 if ( is_array( $oldies ) && $oldies !== [] ) {
@@ -869,9 +879,9 @@
869 879 $valid = array_keys( $valid );
870 880
871 881 $query[] = $wpdb->prepare(
872 882 "( meta_key = %s AND meta_value NOT IN ( '" . implode( "','", esc_sql( $valid ) ) . "' ) )",
873 - self::$meta_prefix . $key
883 + self::$meta_prefix . $key,
874 884 );
875 885 unset( $valid );
876 886 }
877 887 elseif ( is_string( $field_def['default_value'] ) && $field_def['default_value'] !== '' ) {
@@ -877,15 +887,15 @@
877 887 elseif ( is_string( $field_def['default_value'] ) && $field_def['default_value'] !== '' ) {
878 888 $query[] = $wpdb->prepare(
879 889 '( meta_key = %s AND meta_value = %s )',
880 890 self::$meta_prefix . $key,
881 - $field_def['default_value']
891 + $field_def['default_value'],
882 892 );
883 893 }
884 894 else {
885 895 $query[] = $wpdb->prepare(
886 896 "( meta_key = %s AND meta_value = '' )",
887 - self::$meta_prefix . $key
897 + self::$meta_prefix . $key,
888 898 );
889 899 }
890 900 }
891 901 }
@@ -921,9 +931,9 @@
921 931 * (hopefully) even smaller set of invalid results.
922 932 */
923 933 $query = $wpdb->prepare(
924 934 "SELECT meta_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s",
925 - self::$meta_prefix . 'meta-robots-adv'
935 + self::$meta_prefix . 'meta-robots-adv',
926 936 );
927 937 $oldies = $wpdb->get_results( $query );
928 938
929 939 if ( is_array( $oldies ) && $oldies !== [] ) {
@@ -1008,9 +1018,9 @@
1008 1018
1009 1019 /**
1010 1020 * The indexable repository.
1011 1021 *
1012 - * @var Indexable_Repository
1022 + * @var Indexable_Repository $repository
1013 1023 */
1014 1024 $repository = YoastSEO()->classes->get( Indexable_Repository::class );
1015 1025
1016 1026 $post_ids = $repository->query()
@@ -1017,48 +1027,90 @@
1017 1027 ->select( 'object_id' )
1018 1028 ->where( 'primary_focus_keyword', $keyword )
1019 1029 ->where( 'object_type', 'post' )
1020 1030 ->where_not_equal( 'object_id', $post_id )
1021 - ->limit( 2 )
1031 + ->where_not_equal( 'post_status', 'trash' )
1032 + ->limit( 2 ) // Limit to 2 results to save time and resources.
1022 1033 ->find_array();
1023 1034
1024 - $callback = static function ( $row ) {
1025 - return (int) $row['object_id'];
1026 - };
1027 - $post_ids = array_map( $callback, $post_ids );
1035 + // Get object_id from each subarray in $post_ids.
1036 + $post_ids = ( is_array( $post_ids ) ) ? array_column( $post_ids, 'object_id' ) : [];
1028 1037
1029 1038 /*
1030 - * If Yoast SEO Premium is active, get the additional keywords as well.
1039 + * If Premium is installed, get the additional keywords as well.
1031 1040 * We only check for the additional keywords if we've not already found two.
1032 1041 * In that case there's no use for an additional query as we already know
1033 1042 * that the keyword has been used multiple times before.
1034 1043 */
1035 - if ( YoastSEO()->helpers->product->is_premium() && count( $post_ids ) < 2 ) {
1036 - $query = [
1037 - 'meta_query' => [
1038 - [
1039 - 'key' => '_yoast_wpseo_focuskeywords',
1040 - 'value' => sprintf( '"keyword":"%s"', $keyword ),
1041 - 'compare' => 'LIKE',
1042 - ],
1043 - ],
1044 - 'post__not_in' => [ $post_id ],
1045 - 'fields' => 'ids',
1046 - 'post_type' => 'any',
1044 + if ( count( $post_ids ) < 2 ) {
1045 + /**
1046 + * Allows enhancing the array of posts' that share their focus keywords with the post's focus keywords.
1047 + *
1048 + * @param array $post_ids The array of posts' ids that share their related keywords with the post.
1049 + * @param string $keyword The keyword to search for.
1050 + * @param int $post_id The id of the post the keyword is associated to.
1051 + */
1052 + $post_ids = apply_filters( 'wpseo_posts_for_focus_keyword', $post_ids, $keyword, $post_id );
1053 + }
1047 1054
1048 - /*
1049 - * We only need to return zero, one or two results:
1050 - * - Zero: keyword hasn't been used before
1051 - * - One: Keyword has been used once before
1052 - * - Two or more: Keyword has been used twice or more before
1053 - */
1054 - 'posts_per_page' => 2,
1055 - ];
1056 - $get_posts = new WP_Query( $query );
1057 - $post_ids = array_merge( $post_ids, $get_posts->posts );
1055 + return $post_ids;
1056 + }
1057 +
1058 + /**
1059 + * Returns the post types for the given post ids.
1060 + *
1061 + * @param array $post_ids The post ids to get the post types for.
1062 + *
1063 + * @return array The post types.
1064 + */
1065 + public static function post_types_for_ids( $post_ids ) {
1066 + // Check if post ids is not empty.
1067 + if ( ! empty( $post_ids ) ) {
1068 + /**
1069 + * The indexable repository.
1070 + *
1071 + * @var Indexable_Repository $repository
1072 + */
1073 + $repository = YoastSEO()->classes->get( Indexable_Repository::class );
1074 +
1075 + // Get the post subtypes for the posts that share the keyword.
1076 + $post_types = $repository->query()
1077 + ->select( 'object_sub_type' )
1078 + ->where_in( 'object_id', $post_ids )
1079 + ->find_array();
1080 +
1081 + // Get object_sub_type from each subarray in $post_ids.
1082 + $post_types = array_column( $post_types, 'object_sub_type' );
1058 1083 }
1084 + else {
1085 + $post_types = [];
1086 + }
1059 1087
1060 - return $post_ids;
1088 + return $post_types;
1089 + }
1090 +
1091 + /**
1092 + * Strips REST-exposed Yoast meta fields from the response for users without edit_post capability on the post.
1093 + *
1094 + * @param WP_REST_Response $response The REST response.
1095 + * @param WP_Post $post The post object.
1096 + *
1097 + * @return WP_REST_Response The (possibly modified) response.
1098 + */
1099 + public static function hide_meta_from_unauthorized_rest_response( $response, $post ) {
1100 + if ( current_user_can( 'edit_post', $post->ID ) ) {
1101 + return $response;
1102 + }
1103 + $data = $response->get_data();
1104 + foreach ( self::$meta_fields as $field_group ) {
1105 + foreach ( $field_group as $key => $field_def ) {
1106 + if ( ! empty( $field_def['show_in_rest'] ) ) {
1107 + unset( $data['meta'][ self::$meta_prefix . $key ] );
1108 + }
1109 + }
1110 + }
1111 + $response->set_data( $data );
1112 + return $response;
1061 1113 }
1062 1114
1063 1115 /**
1064 1116 * Filter the schema article types.