bool ]`. * - Yoast SEO (checked on 28.7): `wpseo_breadcrumb_links` in `Breadcrumbs_Generator::generate()`, which both the trail * and the schema's Breadcrumb piece use. Items are `[ 'url' => …, 'text' => …, 'id' => … ]`. * * Only the middle of the trail changes, and only on a published post or page that has a Studio address. The SEO * plugin's own first step (Home, when it shows one) and its own last steps (the page itself, a page number) are kept * as it made them. When the page's own step cannot be found in the list, the list is returned untouched. * * A site can switch this off with `add_filter( 'growth_combinator_breadcrumbs', '__return_false' );`. * * @package GrowthCombinator */ namespace GrowthCombinator\Modules; use GrowthCombinator\Environment; use GrowthCombinator\Module; use GrowthCombinator\Paths; defined( 'ABSPATH' ) || exit; final class Breadcrumbs implements Module { /** * The steps above each page, per post id, worked out once per request: Rank Math asks for its list twice on every * page (the trail and the schema). * * @var array> */ private $above = array(); /** * {@inheritDoc} */ public function id() { return 'breadcrumbs'; } /** * {@inheritDoc} */ public function register() { add_filter( 'rank_math/frontend/breadcrumb/items', array( $this, 'rank_math' ), 20 ); add_filter( 'wpseo_breadcrumb_links', array( $this, 'yoast' ), 20 ); } /** * {@inheritDoc} */ public function report() { if ( ! $this->wanted() ) { return array( 'state' => 'off', 'say' => __( 'Switched off on this site by the growth_combinator_breadcrumbs filter.', 'growth-combinator' ), ); } $seo = array_intersect( wp_list_pluck( Environment::seo_plugins(), 'id' ), array( 'rank-math', 'yoast' ) ); if ( empty( $seo ) ) { return array( 'state' => 'idle', 'say' => __( 'Neither Rank Math nor Yoast SEO is active, so there are no breadcrumbs to follow addresses.', 'growth-combinator' ), ); } return array( 'state' => 'on', 'say' => __( 'Breadcrumbs, and the breadcrumb data search engines read, follow each Studio page\'s address.', 'growth-combinator' ), ); } /** * Rank Math's list. * * @param array $crumbs Items `[ text, url, 'hide_in_schema' => bool ]`. * @return array */ public function rank_math( $crumbs ) { $home = is_callable( array( '\RankMath\Helper', 'get_settings' ) ) ? (bool) \RankMath\Helper::get_settings( 'general.breadcrumbs_home' ) : null; return $this->rebuild( $crumbs, static fn( $c ) => isset( $c[1] ) ? (string) $c[1] : '', $home, static fn( $s ) => array( $s['text'], $s['url'], 'hide_in_schema' => false ), 'rank_math_breadcrumb_title' ); } /** * Yoast's list. * * @param array $crumbs Items `[ 'url' => …, 'text' => …, 'id' => … ]`. * @return array */ public function yoast( $crumbs ) { $home = is_callable( array( '\WPSEO_Options', 'get' ) ) ? '' !== (string) \WPSEO_Options::get( 'breadcrumbs-home', '' ) : null; return $this->rebuild( $crumbs, static fn( $c ) => isset( $c['url'] ) ? (string) $c['url'] : '', $home, static fn( $s ) => array( 'url' => $s['url'], 'text' => $s['text'], 'id' => $s['id'], ), '_yoast_wpseo_bctitle' ); } /** * The list with its middle replaced by the steps of the page's address. * * @param mixed $crumbs The SEO plugin's list. * @param callable $url_of Reads an item's link. * @param bool|null $home_first Whether the SEO plugin starts the list with Home (null: unknown, judged by the link). * @param callable $make Builds an item in the SEO plugin's shape from a step. * @param string $title_key The post meta key holding the SEO plugin's own breadcrumb title. * @return mixed */ private function rebuild( $crumbs, callable $url_of, $home_first, callable $make, $title_key ) { if ( ! is_array( $crumbs ) || empty( $crumbs ) || ! $this->wanted() ) { return $crumbs; // The switch is read here, not at registration: a theme's functions.php loads after plugins. } $post = $this->placed_page(); if ( null === $post ) { return $crumbs; } $crumbs = array_values( $crumbs ); $self = self::key( get_permalink( $post ) ); $at = null; foreach ( $crumbs as $i => $crumb ) { if ( self::key( $url_of( $crumb ) ) === $self ) { $at = $i; break; } } if ( null === $at ) { return $crumbs; // The page itself is not in the list: not a trail we understand, so it is left as it is. } if ( null === $home_first ) { $home_first = $at > 0 && self::key( $url_of( $crumbs[0] ) ) === self::key( home_url( '/' ) ); } $head = ( $home_first && $at > 0 ) ? array( $crumbs[0] ) : array(); $mid = array_map( $make, $this->steps_above( $post, $title_key ) ); return array_merge( $head, $mid, array_slice( $crumbs, $at ) ); } /** * The page being viewed, when it is a published post or page with a Studio address. * * @return \WP_Post|null */ private function placed_page() { if ( ! is_singular( Addresses::POST_TYPES ) ) { return null; } $post = get_queried_object(); if ( ! $post instanceof \WP_Post || 'publish' !== $post->post_status || '' === Paths::of( $post->ID ) ) { return null; } return $post; } /** * The pages at each part of the address above this one, outermost first. Studio's own pages come from the address * map already in memory; a part Studio has no page at is looked up as the site's own page (one query, which * WordPress caches). * * @param \WP_Post $post The page. * @param string $title_key The SEO plugin's breadcrumb title meta key. * @return array */ private function steps_above( \WP_Post $post, $title_key ) { $cache = $post->ID . '|' . $title_key; if ( isset( $this->above[ $cache ] ) ) { return $this->above[ $cache ]; } // Kept in the object cache (when the site has a persistent one) under both versions that can change a step: // any address change bumps ours, any post or post meta change bumps WordPress's. $key = 'crumbs:' . Paths::generation() . ':' . wp_cache_get_last_changed( 'posts' ) . ':' . $cache; $known = wp_cache_get( $key, 'growth_combinator' ); if ( is_array( $known ) ) { $this->above[ $cache ] = $known; return $known; } $parts = explode( '/', Paths::of( $post->ID ) ); $ids = array(); for ( $n = 1; $n < count( $parts ); $n++ ) { $ids[ implode( '/', array_slice( $parts, 0, $n ) ) ] = Paths::at( implode( '/', array_slice( $parts, 0, $n ) ) ); } // Studio's pages in one query; the site's own pages are found by their path (WordPress caches that lookup). $ours = array_values( array_filter( $ids ) ); if ( $ours ) { _prime_post_caches( $ours, false, false ); } foreach ( $ids as $prefix => $id ) { if ( ! $id ) { $found = get_page_by_path( $prefix, OBJECT, 'page' ); $ids[ $prefix ] = $found instanceof \WP_Post ? (int) $found->ID : 0; } } // Every step's breadcrumb title in one query. $all = array_values( array_filter( $ids ) ); if ( $all ) { update_meta_cache( 'post', $all ); } $steps = array(); foreach ( $ids as $id ) { $page = $id ? get_post( $id ) : null; if ( ! $page instanceof \WP_Post || 'publish' !== $page->post_status || (int) $page->ID === (int) $post->ID ) { continue; } $title = (string) get_post_meta( $page->ID, $title_key, true ); $steps[] = array( 'id' => (int) $page->ID, 'text' => wp_strip_all_tags( '' !== $title ? $title : get_the_title( $page ) ), 'url' => get_permalink( $page ), ); } $this->above[ $cache ] = $steps; wp_cache_set( $key, $steps, 'growth_combinator', HOUR_IN_SECONDS ); return $steps; } /** * One form for comparing links: no scheme, no trailing slash, lowercase. * * @param mixed $url A link. * @return string */ private static function key( $url ) { return strtolower( untrailingslashit( preg_replace( '#^https?:#i', '', (string) $url ) ) ); } /** * Whether the site lets this module work. * * @return bool */ private function wanted() { return (bool) apply_filters( 'growth_combinator_breadcrumbs', true ); } }