Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.06% covered (success)
92.06%
116 / 126
50.00% covered (danger)
50.00%
6 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Breadcrumb_Schema_Node
92.06% covered (success)
92.06%
116 / 126
50.00% covered (danger)
50.00%
6 / 12
78.89
0.00% covered (danger)
0.00%
0 / 1
 build
97.56% covered (success)
97.56%
40 / 41
0.00% covered (danger)
0.00%
0 / 1
18
 singular_items
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
15
 taxonomy_items
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
8.51
 blog_items
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 post_type_archive_items
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 author_items
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 date_items
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
9.37
 add_posts_page
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 posts_page
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 add_item
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 add_current
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 text
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * BreadcrumbList Schema.org node builder.
4 *
5 * @package automattic/jetpack-seo-package
6 */
7
8namespace Automattic\Jetpack\SEO;
9
10/**
11 * Builds a BreadcrumbList for the current public request.
12 */
13class Breadcrumb_Schema_Node {
14
15    /**
16     * Build the breadcrumb node for the current request.
17     *
18     * @return array|null
19     */
20    public static function build() {
21        if ( is_front_page() || is_search() || is_404() ) {
22            return null;
23        }
24
25        $items = array(
26            array(
27                'name' => __( 'Home', 'jetpack-seo' ),
28                'item' => home_url( '/' ),
29            ),
30        );
31
32        if ( is_singular() ) {
33            $items = self::singular_items( $items );
34        } elseif ( is_category() || is_tag() || is_tax() ) {
35            $items = self::taxonomy_items( $items );
36        } elseif ( is_home() ) {
37            $items = self::blog_items( $items );
38        } elseif ( is_post_type_archive() ) {
39            $items = self::post_type_archive_items( $items );
40        } elseif ( is_author() ) {
41            $items = self::author_items( $items );
42        } elseif ( is_date() ) {
43            $items = self::date_items( $items );
44        } else {
45            return null;
46        }
47
48        if ( null === $items || count( $items ) < 2 ) {
49            return null;
50        }
51
52        $list_items = array();
53        $last       = count( $items ) - 1;
54        foreach ( $items as $index => $item ) {
55            // Decode only here: names pass through text() twice, and a second strip would remove decoded text such as "<div>".
56            $name = trim( \WP_HTML_Decoder::decode_text_node( self::text( $item['name'] ?? '' ) ) );
57            if ( '' === $name ) {
58                return null;
59            }
60
61            $list_item = array(
62                '@type'    => 'ListItem',
63                'position' => $index + 1,
64                'name'     => $name,
65            );
66            if ( $index < $last && ! empty( $item['item'] ) ) {
67                $list_item['item'] = $item['item'];
68            }
69            $list_items[] = $list_item;
70        }
71
72        return array(
73            '@type'           => 'BreadcrumbList',
74            'itemListElement' => $list_items,
75        );
76    }
77
78    /**
79     * Add breadcrumbs for a singular post.
80     *
81     * @param array $items Existing items.
82     * @return array|null
83     */
84    private static function singular_items( array $items ) {
85        $post = get_queried_object();
86        if ( ! $post instanceof \WP_Post || 'publish' !== $post->post_status ) {
87            return null;
88        }
89
90        $post_type = get_post_type_object( $post->post_type );
91        if ( ! $post_type || ! is_post_type_viewable( $post_type ) ) {
92            return null;
93        }
94
95        if ( 'post' === $post->post_type ) {
96            self::add_posts_page( $items );
97        } elseif ( 'page' !== $post->post_type && $post_type->has_archive ) {
98            self::add_item( $items, $post_type->labels->name, get_post_type_archive_link( $post->post_type ) );
99        }
100
101        $front_page_id = 'page' === get_option( 'show_on_front' ) ? (int) get_option( 'page_on_front' ) : 0;
102        if ( is_post_type_hierarchical( $post->post_type ) ) {
103            foreach ( array_reverse( get_post_ancestors( $post ) ) as $ancestor_id ) {
104                $ancestor = get_post( $ancestor_id );
105                if ( $ancestor instanceof \WP_Post && $front_page_id === $ancestor->ID ) {
106                    continue;
107                }
108                if ( $ancestor instanceof \WP_Post && 'publish' === $ancestor->post_status ) {
109                    self::add_item( $items, $ancestor->post_title, get_permalink( $ancestor ) );
110                }
111            }
112        }
113
114        return self::add_current( $items, $post->post_title );
115    }
116
117    /**
118     * Add breadcrumbs for a taxonomy archive.
119     *
120     * @param array $items Existing items.
121     * @return array|null
122     */
123    private static function taxonomy_items( array $items ) {
124        $term = get_queried_object();
125        if ( ! $term instanceof \WP_Term ) {
126            return null;
127        }
128
129        $taxonomy = get_taxonomy( $term->taxonomy );
130        if ( ! $taxonomy || ! $taxonomy->public ) {
131            return null;
132        }
133
134        if ( $taxonomy->hierarchical ) {
135            foreach ( array_reverse( get_ancestors( $term->term_id, $term->taxonomy, 'taxonomy' ) ) as $ancestor_id ) {
136                $ancestor = get_term( $ancestor_id, $term->taxonomy );
137                if ( ! $ancestor instanceof \WP_Term ) {
138                    continue;
139                }
140
141                $link = get_term_link( $ancestor );
142                if ( ! is_wp_error( $link ) ) {
143                    self::add_item( $items, $ancestor->name, $link );
144                }
145            }
146        }
147
148        return self::add_current( $items, $term->name );
149    }
150
151    /**
152     * Add the configured posts page as the current blog archive.
153     *
154     * @param array $items Existing items.
155     * @return array|null
156     */
157    private static function blog_items( array $items ) {
158        $page = self::posts_page();
159        return null === $page ? null : self::add_current( $items, $page->post_title );
160    }
161
162    /**
163     * Add a custom post type archive.
164     *
165     * @param array $items Existing items.
166     * @return array|null
167     */
168    private static function post_type_archive_items( array $items ) {
169        $post_type = get_queried_object();
170        if ( ! $post_type instanceof \WP_Post_Type || ! is_post_type_viewable( $post_type ) || ! $post_type->has_archive ) {
171            return null;
172        }
173
174        return self::add_current( $items, $post_type->labels->name );
175    }
176
177    /**
178     * Add an author archive, optionally under the posts page.
179     *
180     * @param array $items Existing items.
181     * @return array|null
182     */
183    private static function author_items( array $items ) {
184        $author = get_queried_object();
185        if ( ! $author instanceof \WP_User || ! $author->exists() ) {
186            return null;
187        }
188
189        self::add_posts_page( $items );
190        return self::add_current( $items, $author->display_name );
191    }
192
193    /**
194     * Add a year, month, or day archive under the posts page.
195     *
196     * @param array $items Existing items.
197     * @return array|null
198     */
199    private static function date_items( array $items ) {
200        global $wp_locale;
201
202        $year  = (int) get_query_var( 'year' );
203        $month = (int) get_query_var( 'monthnum' );
204        $day   = (int) get_query_var( 'day' );
205
206        if ( $year < 1 || $year > 9999 ) {
207            return null;
208        }
209
210        self::add_posts_page( $items );
211        if ( is_year() ) {
212            return self::add_current( $items, (string) $year );
213        }
214
215        if ( $month < 1 || $month > 12 ) {
216            return null;
217        }
218
219        self::add_item( $items, (string) $year, get_year_link( $year ) );
220        $month_name = $wp_locale->get_month( $month );
221        if ( is_month() ) {
222            return self::add_current( $items, $month_name );
223        }
224
225        if ( ! is_day() || ! checkdate( $month, $day, $year ) ) {
226            return null;
227        }
228
229        self::add_item( $items, $month_name, get_month_link( $year, $month ) );
230        return self::add_current( $items, (string) $day );
231    }
232
233    /**
234     * Add the configured, distinct posts page as an ancestor.
235     *
236     * @param array $items Items passed by reference.
237     * @return void
238     */
239    private static function add_posts_page( array &$items ) {
240        $page = self::posts_page();
241        if ( null !== $page && (int) get_option( 'page_on_front' ) !== $page->ID ) {
242            self::add_item( $items, $page->post_title, get_permalink( $page ) );
243        }
244    }
245
246    /**
247     * Get the valid configured posts page.
248     *
249     * @return \WP_Post|null
250     */
251    private static function posts_page() {
252        if ( 'page' !== get_option( 'show_on_front' ) ) {
253            return null;
254        }
255
256        $page_id = (int) get_option( 'page_for_posts' );
257        if ( $page_id < 1 ) {
258            // get_post( 0 ) would return the global post, i.e. the current request's own post.
259            return null;
260        }
261
262        $page = get_post( $page_id );
263        return $page instanceof \WP_Post && 'publish' === $page->post_status && '' !== self::text( $page->post_title ) ? $page : null;
264    }
265
266    /**
267     * Add a navigable ancestor when its name and URL are valid.
268     *
269     * @param array  $items Items passed by reference.
270     * @param mixed  $name  Item name.
271     * @param string $url   Item URL.
272     * @return void
273     */
274    private static function add_item( array &$items, $name, $url ) {
275        $name = self::text( $name );
276        if ( '' !== $name && is_string( $url ) && '' !== $url ) {
277            $items[] = array(
278                'name' => $name,
279                'item' => $url,
280            );
281        }
282    }
283
284    /**
285     * Add the non-linked current item.
286     *
287     * @param array $items Existing items.
288     * @param mixed $name  Current item name.
289     * @return array|null
290     */
291    private static function add_current( array $items, $name ) {
292        $name = self::text( $name );
293        if ( '' === $name ) {
294            return null;
295        }
296
297        $items[] = array( 'name' => $name );
298        return $items;
299    }
300
301    /**
302     * Normalize a breadcrumb name to trimmed plain text.
303     *
304     * @param mixed $value Raw value.
305     * @return string
306     */
307    private static function text( $value ) {
308        return is_string( $value ) ? trim( wp_strip_all_tags( $value ) ) : '';
309    }
310}