| @@ -14,15 +14,25 @@ | ||
| 14 | 14 | */ |
| 15 | 15 | const DESCRIPTION_META_KEY = 'advanced_seo_description'; |
| 16 | 16 | const HTML_TITLE_META_KEY = 'jetpack_seo_html_title'; |
| 17 | 17 | const NOINDEX_META_KEY = 'jetpack_seo_noindex'; |
| 18 | + const SCHEMA_TYPE_META_KEY = 'jetpack_seo_schema_type'; | |
| 18 | 19 | const POST_META_KEYS_ARRAY = array( |
| 19 | 20 | self::DESCRIPTION_META_KEY, |
| 20 | 21 | self::HTML_TITLE_META_KEY, |
| 21 | 22 | self::NOINDEX_META_KEY, |
| 23 | + self::SCHEMA_TYPE_META_KEY, | |
| 22 | 24 | ); |
| 23 | 25 | |
| 24 | 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 | + /** | |
| 25 | 35 | * Build meta description for post SEO. |
| 26 | 36 | * |
| 27 | 37 | * @param WP_Post|null $post Source of data for custom description. |
| 28 | 38 | * |
| @@ -33,9 +43,10 @@ | ||
| 33 | 43 | if ( ! ( $post instanceof WP_Post ) ) { |
| 34 | 44 | return ''; |
| 35 | 45 | } |
| 36 | 46 | |
| 37 | - if ( post_password_required() || ! is_singular() ) { | |
| 47 | + // Check the post being described, not the global one. | |
| 48 | + if ( post_password_required( $post ) || ! is_singular() ) { | |
| 38 | 49 | return ''; |
| 39 | 50 | } |
| 40 | 51 | |
| 41 | 52 | // Business users can overwrite the description. |
| @@ -48,9 +59,19 @@ | ||
| 48 | 59 | if ( ! empty( $post->post_excerpt ) ) { |
| 49 | 60 | return $post->post_excerpt; |
| 50 | 61 | } |
| 51 | 62 | |
| 52 | - return $post->post_content; | |
| 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 ); | |
| 53 | 74 | } |
| 54 | 75 | |
| 55 | 76 | /** |
| 56 | 77 | * Returns post's custom meta description if it is set, and if |
| @@ -138,11 +159,13 @@ | ||
| 138 | 159 | return $skip; |
| 139 | 160 | } |
| 140 | 161 | |
| 141 | 162 | /** |
| 142 | - * Registers the following meta keys for use in the REST API: | |
| 163 | + * Registers the SEO post meta keys for use in the REST API: | |
| 143 | 164 | * - self::DESCRIPTION_META_KEY |
| 144 | 165 | * - self::HTML_TITLE_META_KEY |
| 166 | + * - self::NOINDEX_META_KEY | |
| 167 | + * - self::SCHEMA_TYPE_META_KEY | |
| 145 | 168 | */ |
| 146 | 169 | public static function register_post_meta() { |
| 147 | 170 | $description_args = array( |
| 148 | 171 | 'type' => 'string', |
| @@ -173,9 +196,84 @@ | ||
| 173 | 196 | 'name' => self::NOINDEX_META_KEY, |
| 174 | 197 | ), |
| 175 | 198 | ); |
| 176 | 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 | + | |
| 177 | 218 | register_meta( 'post', self::DESCRIPTION_META_KEY, $description_args ); |
| 178 | 219 | register_meta( 'post', self::HTML_TITLE_META_KEY, $html_title_args ); |
| 179 | 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 | + ); | |
| 180 | 278 | } |
| 181 | 279 | } |