Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
98.65% |
146 / 148 |
|
81.82% |
9 / 11 |
CRAP | |
0.00% |
0 / 1 |
| Nav_Menu_Pages | |
98.65% |
146 / 148 |
|
81.82% |
9 / 11 |
82 | |
0.00% |
0 / 1 |
| get | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| is_main_location | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| classic_menu_pages | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
11 | |||
| block_theme_menu_pages | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
11 | |||
| home_template_part_slugs | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
5.01 | |||
| template_part_slugs | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
7 | |||
| page_ids_in_blocks | |
100.00% |
29 / 29 |
|
100.00% |
1 / 1 |
24 | |||
| referenced_content | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
12 | |||
| is_active_theme_part | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
3 | |||
| local_page_id | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
3 | |||
| fallback_navigation | |
93.33% |
14 / 15 |
|
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 | |
| 8 | namespace 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 | */ |
| 14 | class 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 | } |