Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.47% covered (warning)
76.47%
91 / 119
36.36% covered (danger)
36.36%
4 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_SEO_Titles
76.47% covered (warning)
76.47%
91 / 119
36.36% covered (danger)
36.36%
4 / 11
109.47
0.00% covered (danger)
0.00%
0 / 1
 get_custom_title_formats
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 get_allowed_tokens
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 get_custom_title
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
13
 get_title_post
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_token_value
55.56% covered (warning)
55.56%
10 / 18
0.00% covered (danger)
0.00%
0 / 1
16.11
 get_page_type
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
10.08
 get_archive_title
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 is_conflicted_theme
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 are_valid_title_formats
47.06% covered (danger)
47.06%
8 / 17
0.00% covered (danger)
0.00%
0 / 1
28.95
 sanitize_title_formats
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 update_title_formats
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
1<?php
2/**
3 * Class containing utility static methods for managing SEO custom title formats.
4 *
5 * @package automattic/jetpack
6 */
7
8/*
9 * Each title format is an array of arrays containing two values:
10 *  - type
11 *  - value
12 *
13 * Possible values for type are: 'token' and 'string'.
14 * Possible values for 'value' are: any string in case that 'type' is set
15 * to 'string', or allowed token values for page type in case that 'type'
16 * is set to 'token'.
17 *
18 * Examples of valid formats:
19 *
20 * [
21 *  'front_page' => [
22 *      [ 'type' => 'string', 'value' => 'Front page title and site name:'],
23 *      [ 'type' => 'token', 'value' => 'site_name']
24 *  ],
25 *  'posts' => [
26 *      [ 'type' => 'token', 'value' => 'site_name' ],
27 *      [ 'type' => 'string', 'value' => ' | ' ],
28 *      [ 'type' => 'token', 'value' => 'post_title' ]
29 *  ],
30 *  'pages' => [],
31 *  'groups' => [],
32 *  'archives' => []
33 * ]
34 *  Custom title for given page type is created by concatenating all of the array 'value' parts.
35 *  Tokens are replaced with their corresponding values for current site.
36 *  Empty array signals that we are not overriding the default title for particular page type.
37 */
38
39/**
40 * Class containing utility static methods for managing SEO custom title formats.
41 */
42class Jetpack_SEO_Titles {
43    /**
44     * Site option name used to store custom title formats.
45     */
46    const TITLE_FORMATS_OPTION = 'advanced_seo_title_formats';
47
48    /**
49     * Retrieves custom title formats from site option.
50     *
51     * @return array Array of custom title formats, or empty array.
52     */
53    public static function get_custom_title_formats() {
54        if ( Jetpack_SEO_Utils::is_enabled_jetpack_seo() ) {
55            return get_option( self::TITLE_FORMATS_OPTION, array() );
56        }
57
58        return array();
59    }
60
61    /**
62     * Returns tokens that are currently supported for each page type.
63     *
64     * @return array Array of allowed token strings.
65     */
66    public static function get_allowed_tokens() {
67        return array(
68            'front_page' => array( 'site_name', 'tagline' ),
69            'posts'      => array( 'site_name', 'tagline', 'post_title' ),
70            'pages'      => array( 'site_name', 'tagline', 'page_title' ),
71            'groups'     => array( 'site_name', 'tagline', 'group_title' ),
72            'archives'   => array( 'site_name', 'tagline', 'date', 'archive_title' ),
73        );
74    }
75
76    /**
77     * Used to modify the default title with custom SEO title.
78     *
79     * @param string $default_title Default title for current page.
80     *
81     * @return string A custom per-post title, custom title structure with replaced tokens, or default title.
82     */
83    public static function get_custom_title( $default_title = '' ) {
84        // Don't filter title for unsupported themes.
85        if ( self::is_conflicted_theme() ) {
86            return $default_title;
87        }
88
89        $page_type = self::get_page_type();
90
91        // Keep default title if invalid page type is supplied.
92        if ( empty( $page_type ) ) {
93            return $default_title;
94        }
95
96        if ( ! Jetpack_SEO_Utils::is_enabled_jetpack_seo() ) {
97            return $default_title;
98        }
99
100        // If it's a page (the posts page included) or post, check for a meta title override. Also
101        // check a static front page, where get_post() returns that page. On a latest-posts
102        // homepage it would instead return the first post in the loop, letting the newest
103        // post's SEO title hijack the homepage title.
104        $check_post_meta = 'pages' === $page_type
105            || 'posts' === $page_type
106            || ( 'front_page' === $page_type && 'page' === get_option( 'show_on_front' ) );
107
108        if ( $check_post_meta ) {
109            $post = self::get_title_post();
110            if ( $post instanceof WP_Post ) {
111                $custom_title = get_post_meta( $post->ID, Jetpack_SEO_Posts::HTML_TITLE_META_KEY, true );
112                if ( ! empty( trim( $custom_title ) ) ) {
113                    return esc_html( $custom_title );
114                }
115            }
116        }
117
118        $title_formats = self::get_custom_title_formats();
119
120        // Keep default title if user has not defined custom title for this page type.
121        if ( empty( $title_formats[ $page_type ] ) ) {
122            return $default_title;
123        }
124
125        $custom_title = '';
126        $format_array = $title_formats[ $page_type ];
127
128        foreach ( $format_array as $item ) {
129            if ( 'token' === $item['type'] ) {
130                $custom_title .= self::get_token_value( $item['value'] );
131            } else {
132                $custom_title .= $item['value'];
133            }
134        }
135
136        return esc_html( $custom_title );
137    }
138
139    /**
140     * Gets the post whose title applies to the current view.
141     *
142     * @return WP_Post|null The viewed posts page, else the global post.
143     */
144    private static function get_title_post() {
145        $posts_page = Jetpack_SEO_Utils::get_viewed_posts_page();
146
147        return $posts_page ? $posts_page : get_post();
148    }
149
150    /**
151     * Returns string value for given token.
152     *
153     * @param string $token_name The token name value that should be replaced.
154     *
155     * @return string Token replacement for current site, or empty string for unknown token name.
156     */
157    public static function get_token_value( $token_name ) {
158
159        switch ( $token_name ) {
160            case 'site_name':
161                return get_bloginfo( 'name' );
162
163            case 'tagline':
164                return get_bloginfo( 'description' );
165
166            case 'post_title':
167            case 'page_title':
168                return the_title_attribute(
169                    array(
170                        'echo' => false,
171                        'post' => self::get_title_post(),
172                    )
173                );
174
175            case 'group_title':
176                return single_tag_title( '', false );
177
178            case 'date':
179            case 'archive_title':
180                return self::get_archive_title();
181
182            default:
183                return '';
184        }
185    }
186
187    /**
188     * Returns page type for current page. We need this helper in order to determine what
189     * user defined title format should be used for custom title.
190     *
191     * @return string|bool Type of current page or false if unsupported.
192     */
193    public static function get_page_type() {
194
195        if ( is_front_page() ) {
196            return 'front_page';
197        }
198
199        if ( is_category() || is_tag() || is_tax() ) {
200            return 'groups';
201        }
202
203        if ( is_archive() && ! is_author() ) {
204            return 'archives';
205        }
206
207        if ( is_page() || Jetpack_SEO_Utils::get_viewed_posts_page() ) {
208            return 'pages';
209        }
210
211        if ( is_singular() ) {
212            return 'posts';
213        }
214
215        return false;
216    }
217
218    /**
219     * Returns the value that should be used as a replacement for the `date` or `archive_title` tokens.
220     * For date-based archives, a date is returned. Otherwise the `post_type_archive_title` is returned.
221     *
222     * The `archive_title` token was added after the `date` token to provide a more generic option
223     * that would work for non date-based archives.
224     *
225     * @return string Token replaced string.
226     */
227    public static function get_archive_title() {
228        // If archive year, month, and day are specified.
229        if ( is_day() ) {
230            return get_the_date();
231        }
232
233        // If archive year, and month are specified.
234        if ( is_month() ) {
235            return trim( single_month_title( ' ', false ) );
236        }
237
238        // Only archive year is specified.
239        if ( is_year() ) {
240            return get_query_var( 'year' );
241        }
242
243        // Not a date based archive.
244        // An example would be "Projects" for Jetpack's Portoflio CPT.
245        return post_type_archive_title( '', false );
246    }
247
248    /**
249     * Checks if current theme is defining custom title that won't work nicely
250     * with our custom SEO title override.
251     *
252     * @return bool True if current theme sets custom title, false otherwise.
253     */
254    public static function is_conflicted_theme() {
255        /**
256         * Can be used to specify a list of themes that use their own custom title format.
257         *
258         * If current site is using one of the themes listed as conflicting,
259         * Jetpack SEO custom title formats will be disabled.
260         *
261         * @module seo-tools
262         *
263         * @since 4.4.0
264         *
265         * @param array List of conflicted theme names. Defaults to empty array.
266         */
267        $conflicted_themes = apply_filters( 'jetpack_seo_custom_title_conflicted_themes', array() );
268
269        return isset( $conflicted_themes[ get_option( 'template' ) ] );
270    }
271
272    /**
273     * Checks if a given format conforms to predefined SEO title templates.
274     *
275     * Every format type and token must be specifically allowed.
276     *
277     * @see get_allowed_tokens()
278     *
279     * @param array $title_formats Template of SEO title to check.
280     *
281     * @return bool True if the formats are valid, false otherwise.
282     */
283    public static function are_valid_title_formats( $title_formats ) {
284        $allowed_tokens = self::get_allowed_tokens();
285
286        if ( ! is_array( $title_formats ) ) {
287            return false;
288        }
289
290        foreach ( $title_formats as $format_type => $format_array ) {
291            if ( ! array_key_exists( $format_type, $allowed_tokens ) ) {
292                return false;
293            }
294
295            if ( '' === $format_array ) {
296                continue;
297            }
298
299            if ( ! is_array( $format_array ) ) {
300                return false;
301            }
302
303            foreach ( $format_array as $item ) {
304                if ( empty( $item['type'] ) || empty( $item['value'] ) ) {
305                    return false;
306                }
307
308                if ( 'token' === $item['type'] ) {
309                    if ( ! in_array( $item['value'], $allowed_tokens[ $format_type ], true ) ) {
310                        return false;
311                    }
312                }
313            }
314        }
315
316        return true;
317    }
318
319    /**
320     * Sanitizes the arbitrary user input strings for custom SEO titles.
321     *
322     * @param array $title_formats Array of custom title formats.
323     *
324     * @return array The sanitized array.
325     */
326    public static function sanitize_title_formats( $title_formats ) {
327        foreach ( $title_formats as &$format_array ) {
328            // The API accepts an empty string as "clear this page type"; store it as
329            // the empty list every reader expects, and don't iterate a string.
330            if ( ! is_array( $format_array ) ) {
331                $format_array = array();
332                continue;
333            }
334            foreach ( $format_array as &$item ) {
335                if ( 'string' === $item['type'] ) {
336                    // From `wp_strip_all_tags`, but omitting the `trim` portion since we want spacing preserved.
337                    $item['value'] = preg_replace( '@<(script|style)[^>]*?>.*?</\\1>@si', '', $item['value'] );
338                    $item['value'] = strip_tags( $item['value'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags
339                    $item['value'] = preg_replace( '/[\r\n\t ]+/', ' ', $item['value'] );
340                }
341            }
342        }
343        unset( $format_array );
344        unset( $item );
345
346        return $title_formats;
347    }
348
349    /**
350     * Combines the previous values of title formats, stored as array in site options,
351     * with the new values that are provided.
352     *
353     * @param array $new_formats Array containing new title formats.
354     *
355     * @return array $result Array of updated title formats, or empty array if no update was performed.
356     */
357    public static function update_title_formats( $new_formats ) {
358        $new_formats = self::sanitize_title_formats( $new_formats );
359
360        // Empty array signals that custom title shouldn't be used.
361        $empty_formats = array(
362            'front_page' => array(),
363            'posts'      => array(),
364            'pages'      => array(),
365            'groups'     => array(),
366            'archives'   => array(),
367        );
368
369        // Sanitize the stored formats too: a page type saved as '' before this guard
370        // existed would otherwise survive every partial save from Calypso.
371        $previous_formats = self::get_custom_title_formats();
372        $previous_formats = is_array( $previous_formats ) ? self::sanitize_title_formats( $previous_formats ) : array();
373
374        $result = array_merge( $empty_formats, $previous_formats, $new_formats );
375
376        if ( update_option( self::TITLE_FORMATS_OPTION, $result ) ) {
377            return $result;
378        }
379
380        return array();
381    }
382}