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