Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
48.15% covered (danger)
48.15%
52 / 108
30.00% covered (danger)
30.00%
3 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_SEO_Titles
48.15% covered (danger)
48.15%
52 / 108
30.00% covered (danger)
30.00%
3 / 10
493.19
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
84.62% covered (warning)
84.62%
22 / 26
0.00% covered (danger)
0.00%
0 / 1
13.62
 get_token_value
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
90
 get_page_type
18.18% covered (danger)
18.18%
2 / 11
0.00% covered (danger)
0.00%
0 / 1
53.36
 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%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 update_title_formats
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
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 singular -- page 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 = get_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     * Returns string value for given token.
141     *
142     * @param string $token_name The token name value that should be replaced.
143     *
144     * @return string Token replacement for current site, or empty string for unknown token name.
145     */
146    public static function get_token_value( $token_name ) {
147
148        switch ( $token_name ) {
149            case 'site_name':
150                return get_bloginfo( 'name' );
151
152            case 'tagline':
153                return get_bloginfo( 'description' );
154
155            case 'post_title':
156            case 'page_title':
157                return the_title_attribute( array( 'echo' => false ) );
158
159            case 'group_title':
160                return single_tag_title( '', false );
161
162            case 'date':
163            case 'archive_title':
164                return self::get_archive_title();
165
166            default:
167                return '';
168        }
169    }
170
171    /**
172     * Returns page type for current page. We need this helper in order to determine what
173     * user defined title format should be used for custom title.
174     *
175     * @return string|bool Type of current page or false if unsupported.
176     */
177    public static function get_page_type() {
178
179        if ( is_front_page() ) {
180            return 'front_page';
181        }
182
183        if ( is_category() || is_tag() || is_tax() ) {
184            return 'groups';
185        }
186
187        if ( is_archive() && ! is_author() ) {
188            return 'archives';
189        }
190
191        if ( is_page() ) {
192            return 'pages';
193        }
194
195        if ( is_singular() ) {
196            return 'posts';
197        }
198
199        return false;
200    }
201
202    /**
203     * Returns the value that should be used as a replacement for the `date` or `archive_title` tokens.
204     * For date-based archives, a date is returned. Otherwise the `post_type_archive_title` is returned.
205     *
206     * The `archive_title` token was added after the `date` token to provide a more generic option
207     * that would work for non date-based archives.
208     *
209     * @return string Token replaced string.
210     */
211    public static function get_archive_title() {
212        // If archive year, month, and day are specified.
213        if ( is_day() ) {
214            return get_the_date();
215        }
216
217        // If archive year, and month are specified.
218        if ( is_month() ) {
219            return trim( single_month_title( ' ', false ) );
220        }
221
222        // Only archive year is specified.
223        if ( is_year() ) {
224            return get_query_var( 'year' );
225        }
226
227        // Not a date based archive.
228        // An example would be "Projects" for Jetpack's Portoflio CPT.
229        return post_type_archive_title( '', false );
230    }
231
232    /**
233     * Checks if current theme is defining custom title that won't work nicely
234     * with our custom SEO title override.
235     *
236     * @return bool True if current theme sets custom title, false otherwise.
237     */
238    public static function is_conflicted_theme() {
239        /**
240         * Can be used to specify a list of themes that use their own custom title format.
241         *
242         * If current site is using one of the themes listed as conflicting,
243         * Jetpack SEO custom title formats will be disabled.
244         *
245         * @module seo-tools
246         *
247         * @since 4.4.0
248         *
249         * @param array List of conflicted theme names. Defaults to empty array.
250         */
251        $conflicted_themes = apply_filters( 'jetpack_seo_custom_title_conflicted_themes', array() );
252
253        return isset( $conflicted_themes[ get_option( 'template' ) ] );
254    }
255
256    /**
257     * Checks if a given format conforms to predefined SEO title templates.
258     *
259     * Every format type and token must be specifically allowed.
260     *
261     * @see get_allowed_tokens()
262     *
263     * @param array $title_formats Template of SEO title to check.
264     *
265     * @return bool True if the formats are valid, false otherwise.
266     */
267    public static function are_valid_title_formats( $title_formats ) {
268        $allowed_tokens = self::get_allowed_tokens();
269
270        if ( ! is_array( $title_formats ) ) {
271            return false;
272        }
273
274        foreach ( $title_formats as $format_type => $format_array ) {
275            if ( ! array_key_exists( $format_type, $allowed_tokens ) ) {
276                return false;
277            }
278
279            if ( '' === $format_array ) {
280                continue;
281            }
282
283            if ( ! is_array( $format_array ) ) {
284                return false;
285            }
286
287            foreach ( $format_array as $item ) {
288                if ( empty( $item['type'] ) || empty( $item['value'] ) ) {
289                    return false;
290                }
291
292                if ( 'token' === $item['type'] ) {
293                    if ( ! in_array( $item['value'], $allowed_tokens[ $format_type ], true ) ) {
294                        return false;
295                    }
296                }
297            }
298        }
299
300        return true;
301    }
302
303    /**
304     * Sanitizes the arbitrary user input strings for custom SEO titles.
305     *
306     * @param array $title_formats Array of custom title formats.
307     *
308     * @return array The sanitized array.
309     */
310    public static function sanitize_title_formats( $title_formats ) {
311        foreach ( $title_formats as &$format_array ) {
312            foreach ( $format_array as &$item ) {
313                if ( 'string' === $item['type'] ) {
314                    // From `wp_strip_all_tags`, but omitting the `trim` portion since we want spacing preserved.
315                    $item['value'] = preg_replace( '@<(script|style)[^>]*?>.*?</\\1>@si', '', $item['value'] );
316                    $item['value'] = strip_tags( $item['value'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags
317                    $item['value'] = preg_replace( '/[\r\n\t ]+/', ' ', $item['value'] );
318                }
319            }
320        }
321        unset( $format_array );
322        unset( $item );
323
324        return $title_formats;
325    }
326
327    /**
328     * Combines the previous values of title formats, stored as array in site options,
329     * with the new values that are provided.
330     *
331     * @param array $new_formats Array containing new title formats.
332     *
333     * @return array $result Array of updated title formats, or empty array if no update was performed.
334     */
335    public static function update_title_formats( $new_formats ) {
336        $new_formats = self::sanitize_title_formats( $new_formats );
337
338        // Empty array signals that custom title shouldn't be used.
339        $empty_formats = array(
340            'front_page' => array(),
341            'posts'      => array(),
342            'pages'      => array(),
343            'groups'     => array(),
344            'archives'   => array(),
345        );
346
347        $previous_formats = self::get_custom_title_formats();
348
349        $result = array_merge( $empty_formats, $previous_formats, $new_formats );
350
351        if ( update_option( self::TITLE_FORMATS_OPTION, $result ) ) {
352            return $result;
353        }
354
355        return array();
356    }
357}