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