Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.19% covered (warning)
80.19%
85 / 106
44.44% covered (danger)
44.44%
4 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_SEO_Posts
80.19% covered (warning)
80.19%
85 / 106
44.44% covered (danger)
44.44%
4 / 9
37.00
0.00% covered (danger)
0.00%
0 / 1
 get_post_description
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
8.12
 get_post_custom_description
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 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        // 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}