| @@ -1,0 +1,279 @@ | ||
| 1 | +<?php | |
| 2 | +/** | |
| 3 | + * Class containing utility static methods for managing SEO options for Posts and Pages. | |
| 4 | + * | |
| 5 | + * @package automattic/jetpack | |
| 6 | + */ | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Provides static utility methods for managing SEO options for Posts and Pages. | |
| 10 | + */ | |
| 11 | +class Jetpack_SEO_Posts { | |
| 12 | + /** | |
| 13 | + * Key of the post meta values that will be used to store post custom data. | |
| 14 | + */ | |
| 15 | + const DESCRIPTION_META_KEY = 'advanced_seo_description'; | |
| 16 | + const HTML_TITLE_META_KEY = 'jetpack_seo_html_title'; | |
| 17 | + const NOINDEX_META_KEY = 'jetpack_seo_noindex'; | |
| 18 | + const SCHEMA_TYPE_META_KEY = 'jetpack_seo_schema_type'; | |
| 19 | + const POST_META_KEYS_ARRAY = array( | |
| 20 | + self::DESCRIPTION_META_KEY, | |
| 21 | + self::HTML_TITLE_META_KEY, | |
| 22 | + self::NOINDEX_META_KEY, | |
| 23 | + self::SCHEMA_TYPE_META_KEY, | |
| 24 | + ); | |
| 25 | + | |
| 26 | + /** | |
| 27 | + * Allowed Schema.org types that can be stored in the per-post schema-type | |
| 28 | + * meta. Empty string means "no override" — Schema_Builder picks a sensible | |
| 29 | + * default for the post. Single source of truth for the meta enum, the | |
| 30 | + * block-editor panel options, and Schema_Builder. | |
| 31 | + */ | |
| 32 | + const ALLOWED_SCHEMA_TYPES = array( '', 'article', 'faq' ); | |
| 33 | + | |
| 34 | + /** | |
| 35 | + * Build meta description for post SEO. | |
| 36 | + * | |
| 37 | + * @param WP_Post|null $post Source of data for custom description. | |
| 38 | + * | |
| 39 | + * @return string Post description or empty string. | |
| 40 | + */ | |
| 41 | + public static function get_post_description( $post = null ) { | |
| 42 | + $post = get_post( $post ); | |
| 43 | + if ( ! ( $post instanceof WP_Post ) ) { | |
| 44 | + return ''; | |
| 45 | + } | |
| 46 | + | |
| 47 | + // Check the post being described, not the global one. | |
| 48 | + if ( post_password_required( $post ) || ! is_singular() ) { | |
| 49 | + return ''; | |
| 50 | + } | |
| 51 | + | |
| 52 | + // Business users can overwrite the description. | |
| 53 | + $custom_description = self::get_post_custom_description( $post ); | |
| 54 | + | |
| 55 | + if ( ! empty( $custom_description ) ) { | |
| 56 | + return $custom_description; | |
| 57 | + } | |
| 58 | + | |
| 59 | + if ( ! empty( $post->post_excerpt ) ) { | |
| 60 | + return $post->post_excerpt; | |
| 61 | + } | |
| 62 | + | |
| 63 | + // The fall-through reads raw post_content, which never passes the `the_content` paywall. | |
| 64 | + $content = $post->post_content; | |
| 65 | + if ( \Automattic\Jetpack\SEO\Content_Gate::is_gated( $post ) ) { | |
| 66 | + $content = \Automattic\Jetpack\SEO\Content_Gate::public_teaser( $post ); | |
| 67 | + if ( '' === $content ) { | |
| 68 | + return ''; | |
| 69 | + } | |
| 70 | + } | |
| 71 | + | |
| 72 | + // Remove content within wp:query blocks and return. | |
| 73 | + return Jetpack_SEO_Utils::remove_query_blocks( $content ); | |
| 74 | + } | |
| 75 | + | |
| 76 | + /** | |
| 77 | + * Returns post's custom meta description if it is set, and if | |
| 78 | + * SEO tools are enabled for current blog. | |
| 79 | + * | |
| 80 | + * @param WP_Post|null $post Source of data for custom description. | |
| 81 | + * | |
| 82 | + * @return string Custom description or empty string | |
| 83 | + */ | |
| 84 | + public static function get_post_custom_description( $post = null ) { | |
| 85 | + $post = get_post( $post ); | |
| 86 | + if ( ! ( $post instanceof WP_Post ) ) { | |
| 87 | + return ''; | |
| 88 | + } | |
| 89 | + | |
| 90 | + $custom_description = get_post_meta( $post->ID, self::DESCRIPTION_META_KEY, true ); | |
| 91 | + | |
| 92 | + if ( empty( $custom_description ) || ! Jetpack_SEO_Utils::is_enabled_jetpack_seo() ) { | |
| 93 | + return ''; | |
| 94 | + } | |
| 95 | + | |
| 96 | + return $custom_description; | |
| 97 | + } | |
| 98 | + | |
| 99 | + /** | |
| 100 | + * Gets a custom HTML title for a post if one is set, and if | |
| 101 | + * SEO tools are enabled for the current blog. | |
| 102 | + * | |
| 103 | + * @param WP_Post|null $post Source of data for the custom HTML title. | |
| 104 | + * | |
| 105 | + * @return string Custom HTML title or an empty string if not set. | |
| 106 | + */ | |
| 107 | + public static function get_post_custom_html_title( $post = null ) { | |
| 108 | + $post = get_post( $post ); | |
| 109 | + if ( ! ( $post instanceof WP_Post ) ) { | |
| 110 | + return ''; | |
| 111 | + } | |
| 112 | + | |
| 113 | + $custom_html_title = get_post_meta( $post->ID, self::HTML_TITLE_META_KEY, true ); | |
| 114 | + | |
| 115 | + if ( empty( $custom_html_title ) || ! Jetpack_SEO_Utils::is_enabled_jetpack_seo() ) { | |
| 116 | + return ''; | |
| 117 | + } | |
| 118 | + | |
| 119 | + return $custom_html_title; | |
| 120 | + } | |
| 121 | + | |
| 122 | + /** | |
| 123 | + * Gets the `jetpack_seo_noindex` setting for a post, if | |
| 124 | + * SEO tools are enabled for the current blog. | |
| 125 | + * | |
| 126 | + * @param WP_Post|null $post Provided post or defaults to the global post. | |
| 127 | + * | |
| 128 | + * @return bool True if post should be marked as noindex, false otherwise. | |
| 129 | + */ | |
| 130 | + public static function get_post_noindex_setting( $post = null ) { | |
| 131 | + $post = get_post( $post ); | |
| 132 | + if ( ! ( $post instanceof WP_Post ) ) { | |
| 133 | + return false; | |
| 134 | + } | |
| 135 | + | |
| 136 | + $mark_as_noindex = get_post_meta( $post->ID, self::NOINDEX_META_KEY, true ); | |
| 137 | + | |
| 138 | + if ( empty( $mark_as_noindex ) || ! Jetpack_SEO_Utils::is_enabled_jetpack_seo() ) { | |
| 139 | + return false; | |
| 140 | + } | |
| 141 | + | |
| 142 | + return (bool) $mark_as_noindex; | |
| 143 | + } | |
| 144 | + | |
| 145 | + /** | |
| 146 | + * Filter callback for `jetpack_sitemap_skip_post`; if a post has `jetpack_seo_noindex` set to true, | |
| 147 | + * then exclude that post from the Jetpack sitemap. | |
| 148 | + * | |
| 149 | + * @param bool $skip Whether to skip the post in the sitemap. | |
| 150 | + * @param WP_Post $post The post to check. | |
| 151 | + * | |
| 152 | + * @return bool | |
| 153 | + */ | |
| 154 | + public static function exclude_noindex_posts_from_jetpack_sitemap( $skip, $post ) { | |
| 155 | + $exclude = self::get_post_noindex_setting( $post ); | |
| 156 | + if ( $exclude ) { | |
| 157 | + $skip = true; | |
| 158 | + } | |
| 159 | + return $skip; | |
| 160 | + } | |
| 161 | + | |
| 162 | + /** | |
| 163 | + * Registers the SEO post meta keys for use in the REST API: | |
| 164 | + * - self::DESCRIPTION_META_KEY | |
| 165 | + * - self::HTML_TITLE_META_KEY | |
| 166 | + * - self::NOINDEX_META_KEY | |
| 167 | + * - self::SCHEMA_TYPE_META_KEY | |
| 168 | + */ | |
| 169 | + public static function register_post_meta() { | |
| 170 | + $description_args = array( | |
| 171 | + 'type' => 'string', | |
| 172 | + 'description' => __( 'Custom post description to be used in HTML <meta /> tag.', 'jetpack' ), | |
| 173 | + 'single' => true, | |
| 174 | + 'default' => '', | |
| 175 | + 'show_in_rest' => array( | |
| 176 | + 'name' => self::DESCRIPTION_META_KEY, | |
| 177 | + ), | |
| 178 | + ); | |
| 179 | + | |
| 180 | + $html_title_args = array( | |
| 181 | + 'type' => 'string', | |
| 182 | + 'description' => __( 'Custom title to be used in HTML <title /> tag.', 'jetpack' ), | |
| 183 | + 'single' => true, | |
| 184 | + 'default' => '', | |
| 185 | + 'show_in_rest' => array( | |
| 186 | + 'name' => self::HTML_TITLE_META_KEY, | |
| 187 | + ), | |
| 188 | + ); | |
| 189 | + | |
| 190 | + $noindex_args = array( | |
| 191 | + 'type' => 'boolean', | |
| 192 | + 'description' => __( 'Whether to hide the post from search engines and the Jetpack sitemap.', 'jetpack' ), | |
| 193 | + 'single' => true, | |
| 194 | + 'default' => false, | |
| 195 | + 'show_in_rest' => array( | |
| 196 | + 'name' => self::NOINDEX_META_KEY, | |
| 197 | + ), | |
| 198 | + ); | |
| 199 | + | |
| 200 | + $schema_type_args = array( | |
| 201 | + 'type' => 'string', | |
| 202 | + 'description' => __( 'Schema.org type to emit as JSON-LD for this post.', 'jetpack' ), | |
| 203 | + 'single' => true, | |
| 204 | + 'default' => '', | |
| 205 | + 'sanitize_callback' => array( __CLASS__, 'sanitize_schema_type' ), | |
| 206 | + 'show_in_rest' => array( | |
| 207 | + 'name' => self::SCHEMA_TYPE_META_KEY, | |
| 208 | + // Enum so core REST rejects an unknown schema type with a proper | |
| 209 | + // rest_invalid_param error; the sanitize_callback is the | |
| 210 | + // defense-in-depth fallback for non-REST writes. | |
| 211 | + 'schema' => array( | |
| 212 | + 'type' => 'string', | |
| 213 | + 'enum' => self::ALLOWED_SCHEMA_TYPES, | |
| 214 | + ), | |
| 215 | + ), | |
| 216 | + ); | |
| 217 | + | |
| 218 | + register_meta( 'post', self::DESCRIPTION_META_KEY, $description_args ); | |
| 219 | + register_meta( 'post', self::HTML_TITLE_META_KEY, $html_title_args ); | |
| 220 | + register_meta( 'post', self::NOINDEX_META_KEY, $noindex_args ); | |
| 221 | + register_meta( 'post', self::SCHEMA_TYPE_META_KEY, $schema_type_args ); | |
| 222 | + } | |
| 223 | + | |
| 224 | + /** | |
| 225 | + * Sanitize a schema type to the allowed list. Unknown values become '' | |
| 226 | + * (no override) rather than erroring, so a non-REST write can't store junk. | |
| 227 | + * | |
| 228 | + * @param string $value The submitted value. | |
| 229 | + * @return string A value from self::ALLOWED_SCHEMA_TYPES. | |
| 230 | + */ | |
| 231 | + public static function sanitize_schema_type( $value ) { | |
| 232 | + $value = is_string( $value ) ? sanitize_key( $value ) : ''; | |
| 233 | + return in_array( $value, self::ALLOWED_SCHEMA_TYPES, true ) ? $value : ''; | |
| 234 | + } | |
| 235 | + | |
| 236 | + /** | |
| 237 | + * Get the per-post schema-type override, if any. | |
| 238 | + * | |
| 239 | + * @param WP_Post|int|null $post Post or post ID. | |
| 240 | + * @return string A value from self::ALLOWED_SCHEMA_TYPES ('' = no override). | |
| 241 | + */ | |
| 242 | + public static function get_post_schema_type( $post = null ) { | |
| 243 | + $post = get_post( $post ); | |
| 244 | + if ( ! ( $post instanceof WP_Post ) ) { | |
| 245 | + return ''; | |
| 246 | + } | |
| 247 | + return self::sanitize_schema_type( (string) get_post_meta( $post->ID, self::SCHEMA_TYPE_META_KEY, true ) ); | |
| 248 | + } | |
| 249 | + | |
| 250 | + /** | |
| 251 | + * Factual per-post SEO field coverage — presence/state only, never a score. | |
| 252 | + * | |
| 253 | + * Single source of truth shared by the Content tab, the edit.php columns, | |
| 254 | + * and the Overview coverage card so the three never drift. Reports whether | |
| 255 | + * each field has been *set*, independent of whether SEO tools are currently | |
| 256 | + * active (this is an authoring/audit view, not front-end emission). | |
| 257 | + * | |
| 258 | + * @param WP_Post|int|null $post Post or post ID. | |
| 259 | + * @return array{has_custom_title:bool,has_description:bool,has_schema_type:bool,noindex:bool} | |
| 260 | + */ | |
| 261 | + public static function get_post_seo_coverage( $post = null ) { | |
| 262 | + $post = get_post( $post ); | |
| 263 | + if ( ! ( $post instanceof WP_Post ) ) { | |
| 264 | + return array( | |
| 265 | + 'has_custom_title' => false, | |
| 266 | + 'has_description' => false, | |
| 267 | + 'has_schema_type' => false, | |
| 268 | + 'noindex' => false, | |
| 269 | + ); | |
| 270 | + } | |
| 271 | + | |
| 272 | + return array( | |
| 273 | + 'has_custom_title' => '' !== (string) get_post_meta( $post->ID, self::HTML_TITLE_META_KEY, true ), | |
| 274 | + 'has_description' => '' !== (string) get_post_meta( $post->ID, self::DESCRIPTION_META_KEY, true ), | |
| 275 | + 'has_schema_type' => '' !== self::get_post_schema_type( $post ), | |
| 276 | + 'noindex' => (bool) get_post_meta( $post->ID, self::NOINDEX_META_KEY, true ), | |
| 277 | + ); | |
| 278 | + } | |
| 279 | +} | |