Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.65% covered (success)
98.65%
146 / 148
81.82% covered (warning)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Nav_Menu_Pages
98.65% covered (success)
98.65%
146 / 148
81.82% covered (warning)
81.82%
9 / 11
82
0.00% covered (danger)
0.00%
0 / 1
 get
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 is_main_location
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 classic_menu_pages
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
11
 block_theme_menu_pages
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
11
 home_template_part_slugs
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 template_part_slugs
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 page_ids_in_blocks
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
24
 referenced_content
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
12
 is_active_theme_part
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 local_page_id
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 fallback_navigation
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
2.00
1<?php
2/**
3 * Pages a site links from its navigation menus.
4 *
5 * @package automattic/jetpack-seo-package
6 */
7
8namespace Automattic\Jetpack\SEO;
9
10/**
11 * Finds the pages linked from the site's main menu and from its other displayed menus,
12 * for both classic menu locations and block theme Navigation blocks.
13 */
14class Nav_Menu_Pages {
15
16    /**
17     * How many pattern or menu references to follow in a row, so one that includes itself
18     * can't recurse forever.
19     */
20    const MAX_DEPTH = 5;
21
22    /**
23     * Location slugs that never hold the main menu, even when they also say `main` or `header`.
24     */
25    const NOT_MAIN_PATTERN = '/footer|secondary|social/';
26
27    /**
28     * Core's fallback menu for this lookup: `false` until fetched, then a post or null.
29     *
30     * @var \WP_Post|null|false
31     */
32    private static $fallback_menu = false;
33
34    /**
35     * Page IDs from the main menu and from the other displayed menus, each in menu order,
36     * with no page in both lists.
37     *
38     * @return array{main: int[], other: int[]}
39     */
40    public static function get() {
41        self::$fallback_menu = false;
42        $classic             = self::classic_menu_pages();
43        $block               = self::block_theme_menu_pages();
44
45        $main  = array_values( array_unique( array_merge( $classic['main'], $block['main'] ) ) );
46        $other = array_values( array_diff( array_unique( array_merge( $classic['other'], $block['other'] ) ), $main ) );
47
48        return array(
49            'main'  => $main,
50            'other' => $other,
51        );
52    }
53
54    /**
55     * Whether a classic menu location holds the main menu by theme convention, e.g.
56     * `primary`, `primary-menu`, `main_menu`, `header` or `menu-1`.
57     *
58     * @param string $location Menu location slug.
59     * @return bool
60     */
61    public static function is_main_location( $location ) {
62        if ( preg_match( self::NOT_MAIN_PATTERN, $location ) ) {
63            return false;
64        }
65        return in_array( $location, array( 'menu-1', 'top' ), true )
66            || (bool) preg_match( '/(?:^|[-_])(?:primary|main|header)(?:[-_]|$)/', $location );
67    }
68
69    /**
70     * Pages in the menus assigned to the theme's registered locations. When no location
71     * name looks like the main menu, the first assigned one that isn't a footer, secondary
72     * or social menu is used.
73     *
74     * @return array{main: int[], other: int[]}
75     */
76    private static function classic_menu_pages() {
77        $pages     = array(
78            'main'  => array(),
79            'other' => array(),
80        );
81        $assigned  = array_filter( (array) get_nav_menu_locations() );
82        $locations = array_intersect( array_keys( get_registered_nav_menus() ), array_keys( $assigned ) );
83        if ( empty( $locations ) ) {
84            return $pages;
85        }
86
87        $main = array_filter( $locations, array( __CLASS__, 'is_main_location' ) );
88        if ( empty( $main ) ) {
89            $main = array_slice( preg_grep( self::NOT_MAIN_PATTERN, $locations, PREG_GREP_INVERT ), 0, 1 );
90        }
91
92        foreach ( $locations as $location ) {
93            $group = in_array( $location, $main, true ) ? 'main' : 'other';
94            $items = wp_get_nav_menu_items( $assigned[ $location ] );
95            foreach ( is_array( $items ) ? $items : array() as $item ) {
96                if ( 'post_type' === $item->type && 'page' === $item->object ) {
97                    $pages[ $group ][] = (int) $item->object_id;
98                } elseif ( 'custom' === $item->type ) {
99                    $id = self::local_page_id( (string) $item->url );
100                    if ( $id ) {
101                        $pages[ $group ][] = $id;
102                    }
103                }
104            }
105        }
106
107        return $pages;
108    }
109
110    /**
111     * Pages in the Navigation blocks of a block theme's header (main) and footer (other)
112     * template parts, preferring the parts the home page uses.
113     *
114     * @return array{main: int[], other: int[]}
115     */
116    private static function block_theme_menu_pages() {
117        $pages = array(
118            'main'  => array(),
119            'other' => array(),
120        );
121        if ( ! wp_is_block_theme() && ! current_theme_supports( 'block-template-parts' ) ) {
122            return $pages;
123        }
124
125        $areas = array(
126            'header' => array(),
127            'footer' => array(),
128        );
129        foreach ( get_block_templates( array(), 'wp_template_part' ) as $part ) {
130            foreach ( array_keys( $areas ) as $area ) {
131                if ( $area === $part->area || $area === $part->slug ) {
132                    $areas[ $area ][ $part->slug ] = $part;
133                }
134            }
135        }
136
137        $home = array_flip( self::home_template_part_slugs() );
138        foreach ( array(
139            'header' => 'main',
140            'footer' => 'other',
141        ) as $area => $group ) {
142            // Prefer the parts the home page includes, then the one named after the area, so
143            // unused variants such as `header-large-title` don't count.
144            $parts = array_intersect_key( $areas[ $area ], $home );
145            if ( empty( $parts ) ) {
146                $parts = isset( $areas[ $area ][ $area ] ) ? array( $areas[ $area ][ $area ] ) : $areas[ $area ];
147            }
148            foreach ( $parts as $part ) {
149                $pages[ $group ] = array_merge( $pages[ $group ], self::page_ids_in_blocks( parse_blocks( (string) $part->content ) ) );
150            }
151        }
152
153        return $pages;
154    }
155
156    /**
157     * Slugs of the template parts in the block template the home page uses.
158     *
159     * @return string[]
160     */
161    private static function home_template_part_slugs() {
162        $hierarchy = array( 'front-page' );
163        $front_id  = 'page' === get_option( 'show_on_front' ) ? (int) get_option( 'page_on_front' ) : 0;
164        if ( $front_id ) {
165            $custom = get_page_template_slug( $front_id );
166            if ( $custom ) {
167                $hierarchy[] = $custom;
168            }
169            $hierarchy[] = 'page-' . urldecode( (string) get_post_field( 'post_name', $front_id ) );
170            array_push( $hierarchy, 'page-' . $front_id, 'page', 'singular' );
171        } else {
172            $hierarchy[] = 'home';
173        }
174        $hierarchy[] = 'index';
175
176        $template = resolve_block_template( 'frontpage', $hierarchy, '' );
177        return $template ? self::template_part_slugs( parse_blocks( (string) $template->content ) ) : array();
178    }
179
180    /**
181     * Slugs of the template part blocks in a block tree, including those inside patterns,
182     * synced patterns and other template parts.
183     *
184     * @param array $blocks Parsed blocks.
185     * @param int   $depth  Pattern or template part references followed so far.
186     * @return string[]
187     */
188    private static function template_part_slugs( array $blocks, $depth = 0 ) {
189        $slugs = array();
190        foreach ( $blocks as $block ) {
191            $name     = $block['blockName'] ?? '';
192            $attrs    = $block['attrs'] ?? array();
193            $inner    = $block['innerBlocks'] ?? array();
194            $followed = false;
195
196            if ( self::is_active_theme_part( $name, $attrs ) ) {
197                $slugs[] = $attrs['slug'];
198            }
199
200            if ( $depth < self::MAX_DEPTH ) {
201                $content = self::referenced_content( $name, $attrs );
202                if ( null !== $content ) {
203                    $inner    = parse_blocks( $content );
204                    $followed = true;
205                }
206            }
207
208            if ( ! empty( $inner ) ) {
209                $slugs = array_merge( $slugs, self::template_part_slugs( $inner, $followed ? $depth + 1 : $depth ) );
210            }
211        }
212        return $slugs;
213    }
214
215    /**
216     * Page IDs linked from the Navigation blocks in a block tree, in order.
217     *
218     * @param array $blocks Parsed blocks.
219     * @param int   $depth  Pattern or menu references followed so far.
220     * @return int[]
221     */
222    private static function page_ids_in_blocks( array $blocks, $depth = 0 ) {
223        $ids = array();
224        foreach ( $blocks as $block ) {
225            $name     = $block['blockName'] ?? '';
226            $attrs    = $block['attrs'] ?? array();
227            $inner    = $block['innerBlocks'] ?? array();
228            $followed = false;
229
230            if ( 'core/navigation' === $name && ( ! empty( $attrs['ref'] ) || empty( $inner ) ) && $depth < self::MAX_DEPTH ) {
231                // Like core, a menu reference replaces the block's own links, and the block falls back when it
232                // has neither or that menu isn't published; a deleted menu shows nothing.
233                $menu  = empty( $attrs['ref'] ) ? null : get_post( (int) $attrs['ref'] );
234                $inner = array();
235                if ( empty( $attrs['ref'] ) || ( $menu instanceof \WP_Post && 'publish' !== $menu->post_status ) ) {
236                    $menu = self::fallback_navigation();
237                }
238                if ( $menu instanceof \WP_Post && 'wp_navigation' === $menu->post_type && 'publish' === $menu->post_status ) {
239                    $inner    = parse_blocks( $menu->post_content );
240                    $followed = true;
241                }
242            } elseif ( in_array( $name, array( 'core/navigation-link', 'core/navigation-submenu' ), true ) ) {
243                // Only explicit links rank a page; a Page List block shows every page, so it ranks none.
244                if ( ! empty( $attrs['id'] ) && 'post-type' === ( $attrs['kind'] ?? '' ) && 'page' === ( $attrs['type'] ?? '' ) ) {
245                    $ids[] = (int) $attrs['id'];
246                } elseif ( 'custom' === ( $attrs['kind'] ?? 'custom' ) && ! empty( $attrs['url'] ) ) {
247                    $id = self::local_page_id( $attrs['url'] );
248                    if ( $id ) {
249                        $ids[] = $id;
250                    }
251                }
252            } elseif ( $depth < self::MAX_DEPTH ) {
253                $content = self::referenced_content( $name, $attrs );
254                if ( null !== $content ) {
255                    $inner    = parse_blocks( $content );
256                    $followed = true;
257                }
258            }
259
260            if ( ! empty( $inner ) ) {
261                $ids = array_merge( $ids, self::page_ids_in_blocks( $inner, $followed ? $depth + 1 : $depth ) );
262            }
263        }
264        return $ids;
265    }
266
267    /**
268     * Markup a pattern, synced pattern or nested template part block stands in for.
269     *
270     * @param string $name  Block name.
271     * @param array  $attrs Block attributes.
272     * @return string|null Null when the block references nothing.
273     */
274    private static function referenced_content( $name, array $attrs ) {
275        if ( 'core/pattern' === $name && ! empty( $attrs['slug'] ) ) {
276            $pattern = \WP_Block_Patterns_Registry::get_instance()->get_registered( $attrs['slug'] );
277            return (string) ( $pattern['content'] ?? '' );
278        }
279        if ( 'core/block' === $name && ! empty( $attrs['ref'] ) ) {
280            $synced = get_post( (int) $attrs['ref'] );
281            return $synced instanceof \WP_Post && 'wp_block' === $synced->post_type && 'publish' === $synced->post_status ? $synced->post_content : null;
282        }
283        if ( self::is_active_theme_part( $name, $attrs ) ) {
284            // Like core, an unpublished saved copy gives way to the theme's file.
285            $id   = get_stylesheet() . '//' . $attrs['slug'];
286            $part = get_block_template( $id, 'wp_template_part' );
287            if ( $part && 'publish' !== $part->status ) {
288                $part = get_block_file_template( $id, 'wp_template_part' );
289            }
290            return $part ? (string) $part->content : null;
291        }
292        return null;
293    }
294
295    /**
296     * Whether a block is a template part of the active theme, the only ones core renders.
297     *
298     * @param string $name  Block name.
299     * @param array  $attrs Block attributes.
300     * @return bool
301     */
302    private static function is_active_theme_part( $name, array $attrs ) {
303        return 'core/template-part' === $name && ! empty( $attrs['slug'] ) && get_stylesheet() === ( $attrs['theme'] ?? get_stylesheet() );
304    }
305
306    /**
307     * The page a custom menu link points to, so a page linked by URL ranks like one picked
308     * from the page list.
309     *
310     * @param string $url Link URL.
311     * @return int Page ID, or 0 for a link to anything else, including other sites.
312     */
313    private static function local_page_id( $url ) {
314        $id = url_to_postid( $url );
315        return $id && 'page' === get_post_type( $id ) ? $id : 0;
316    }
317
318    /**
319     * The menu a Navigation block without a published menu falls back to: the newest published
320     * `wp_navigation` post, as in core's `WP_Navigation_Fallback`, without creating one.
321     *
322     * @return \WP_Post|null
323     */
324    private static function fallback_navigation() {
325        if ( false !== self::$fallback_menu ) {
326            return self::$fallback_menu;
327        }
328        $menus               = get_posts(
329            array(
330                'post_type'        => 'wp_navigation',
331                'post_status'      => 'publish',
332                'posts_per_page'   => 1,
333                'orderby'          => 'date',
334                'order'            => 'DESC',
335                'no_found_rows'    => true,
336                'suppress_filters' => false,
337            )
338        );
339        self::$fallback_menu = $menus[0] ?? null;
340        return self::$fallback_menu;
341    }
342}