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