Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.87% covered (success)
91.87%
113 / 123
50.00% covered (danger)
50.00%
6 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Breadcrumb_Schema_Node
91.87% covered (success)
91.87%
113 / 123
50.00% covered (danger)
50.00%
6 / 12
78.02
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
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
5.39
 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            $name = self::text( $item['name'] ?? '' );
56            if ( '' === $name ) {
57                return null;
58            }
59
60            $list_item = array(
61                '@type'    => 'ListItem',
62                'position' => $index + 1,
63                'name'     => $name,
64            );
65            if ( $index < $last && ! empty( $item['item'] ) ) {
66                $list_item['item'] = $item['item'];
67            }
68            $list_items[] = $list_item;
69        }
70
71        return array(
72            '@type'           => 'BreadcrumbList',
73            'itemListElement' => $list_items,
74        );
75    }
76
77    /**
78     * Add breadcrumbs for a singular post.
79     *
80     * @param array $items Existing items.
81     * @return array|null
82     */
83    private static function singular_items( array $items ) {
84        $post = get_queried_object();
85        if ( ! $post instanceof \WP_Post || 'publish' !== $post->post_status ) {
86            return null;
87        }
88
89        $post_type = get_post_type_object( $post->post_type );
90        if ( ! $post_type || ! is_post_type_viewable( $post_type ) ) {
91            return null;
92        }
93
94        if ( 'post' === $post->post_type ) {
95            self::add_posts_page( $items );
96        } elseif ( 'page' !== $post->post_type && $post_type->has_archive ) {
97            self::add_item( $items, $post_type->labels->name, get_post_type_archive_link( $post->post_type ) );
98        }
99
100        $front_page_id = 'page' === get_option( 'show_on_front' ) ? (int) get_option( 'page_on_front' ) : 0;
101        if ( is_post_type_hierarchical( $post->post_type ) ) {
102            foreach ( array_reverse( get_post_ancestors( $post ) ) as $ancestor_id ) {
103                $ancestor = get_post( $ancestor_id );
104                if ( $ancestor instanceof \WP_Post && $front_page_id === $ancestor->ID ) {
105                    continue;
106                }
107                if ( $ancestor instanceof \WP_Post && 'publish' === $ancestor->post_status ) {
108                    self::add_item( $items, $ancestor->post_title, get_permalink( $ancestor ) );
109                }
110            }
111        }
112
113        return self::add_current( $items, $post->post_title );
114    }
115
116    /**
117     * Add breadcrumbs for a taxonomy archive.
118     *
119     * @param array $items Existing items.
120     * @return array|null
121     */
122    private static function taxonomy_items( array $items ) {
123        $term = get_queried_object();
124        if ( ! $term instanceof \WP_Term ) {
125            return null;
126        }
127
128        $taxonomy = get_taxonomy( $term->taxonomy );
129        if ( ! $taxonomy || ! $taxonomy->public ) {
130            return null;
131        }
132
133        if ( $taxonomy->hierarchical ) {
134            foreach ( array_reverse( get_ancestors( $term->term_id, $term->taxonomy, 'taxonomy' ) ) as $ancestor_id ) {
135                $ancestor = get_term( $ancestor_id, $term->taxonomy );
136                if ( ! $ancestor instanceof \WP_Term ) {
137                    continue;
138                }
139
140                $link = get_term_link( $ancestor );
141                if ( ! is_wp_error( $link ) ) {
142                    self::add_item( $items, $ancestor->name, $link );
143                }
144            }
145        }
146
147        return self::add_current( $items, $term->name );
148    }
149
150    /**
151     * Add the configured posts page as the current blog archive.
152     *
153     * @param array $items Existing items.
154     * @return array|null
155     */
156    private static function blog_items( array $items ) {
157        $page = self::posts_page();
158        return null === $page ? null : self::add_current( $items, $page->post_title );
159    }
160
161    /**
162     * Add a custom post type archive.
163     *
164     * @param array $items Existing items.
165     * @return array|null
166     */
167    private static function post_type_archive_items( array $items ) {
168        $post_type = get_queried_object();
169        if ( ! $post_type instanceof \WP_Post_Type || ! is_post_type_viewable( $post_type ) || ! $post_type->has_archive ) {
170            return null;
171        }
172
173        return self::add_current( $items, $post_type->labels->name );
174    }
175
176    /**
177     * Add an author archive, optionally under the posts page.
178     *
179     * @param array $items Existing items.
180     * @return array|null
181     */
182    private static function author_items( array $items ) {
183        $author = get_queried_object();
184        if ( ! $author instanceof \WP_User || ! $author->exists() ) {
185            return null;
186        }
187
188        self::add_posts_page( $items );
189        return self::add_current( $items, $author->display_name );
190    }
191
192    /**
193     * Add a year, month, or day archive under the posts page.
194     *
195     * @param array $items Existing items.
196     * @return array|null
197     */
198    private static function date_items( array $items ) {
199        global $wp_locale;
200
201        $year  = (int) get_query_var( 'year' );
202        $month = (int) get_query_var( 'monthnum' );
203        $day   = (int) get_query_var( 'day' );
204
205        if ( $year < 1 || $year > 9999 ) {
206            return null;
207        }
208
209        self::add_posts_page( $items );
210        if ( is_year() ) {
211            return self::add_current( $items, (string) $year );
212        }
213
214        if ( $month < 1 || $month > 12 ) {
215            return null;
216        }
217
218        self::add_item( $items, (string) $year, get_year_link( $year ) );
219        $month_name = $wp_locale->get_month( $month );
220        if ( is_month() ) {
221            return self::add_current( $items, $month_name );
222        }
223
224        if ( ! is_day() || ! checkdate( $month, $day, $year ) ) {
225            return null;
226        }
227
228        self::add_item( $items, $month_name, get_month_link( $year, $month ) );
229        return self::add_current( $items, (string) $day );
230    }
231
232    /**
233     * Add the configured, distinct posts page as an ancestor.
234     *
235     * @param array $items Items passed by reference.
236     * @return void
237     */
238    private static function add_posts_page( array &$items ) {
239        $page = self::posts_page();
240        if ( null !== $page && (int) get_option( 'page_on_front' ) !== $page->ID ) {
241            self::add_item( $items, $page->post_title, get_permalink( $page ) );
242        }
243    }
244
245    /**
246     * Get the valid configured posts page.
247     *
248     * @return \WP_Post|null
249     */
250    private static function posts_page() {
251        if ( 'page' !== get_option( 'show_on_front' ) ) {
252            return null;
253        }
254
255        $page = get_post( (int) get_option( 'page_for_posts' ) );
256        return $page instanceof \WP_Post && 'publish' === $page->post_status && '' !== self::text( $page->post_title ) ? $page : null;
257    }
258
259    /**
260     * Add a navigable ancestor when its name and URL are valid.
261     *
262     * @param array  $items Items passed by reference.
263     * @param mixed  $name  Item name.
264     * @param string $url   Item URL.
265     * @return void
266     */
267    private static function add_item( array &$items, $name, $url ) {
268        $name = self::text( $name );
269        if ( '' !== $name && is_string( $url ) && '' !== $url ) {
270            $items[] = array(
271                'name' => $name,
272                'item' => $url,
273            );
274        }
275    }
276
277    /**
278     * Add the non-linked current item.
279     *
280     * @param array $items Existing items.
281     * @param mixed $name  Current item name.
282     * @return array|null
283     */
284    private static function add_current( array $items, $name ) {
285        $name = self::text( $name );
286        if ( '' === $name ) {
287            return null;
288        }
289
290        $items[] = array( 'name' => $name );
291        return $items;
292    }
293
294    /**
295     * Normalize a breadcrumb name to trimmed plain text.
296     *
297     * @param mixed $value Raw value.
298     * @return string
299     */
300    private static function text( $value ) {
301        return is_string( $value ) ? trim( wp_strip_all_tags( $value ) ) : '';
302    }
303}