Commit b779d7a0431 for woocommerce
commit b779d7a043104f14814f6ab7a8a2ec7e0e264b5e
Author: Karol Manijak <20098064+kmanijak@users.noreply.github.com>
Date: Tue Jul 21 14:27:39 2026 +0200
Make Core Breadcrumbs compatible with WooCommerce Store Breadcrumbs (#66382)
* Fix Core Breadcrumbs WooCommerce compatibility
* Simplify Core Breadcrumbs compatibility helpers
* Simplify Core Breadcrumbs compatibility scope
* Stabilize paginated product search breadcrumb test
* Make product search breadcrumb test deterministic
* Limit Core Breadcrumbs compatibility to Woo contexts
* Restore search and legacy breadcrumb filter coverage
* Restore global breadcrumb home URL filter
* Remove redundant breadcrumb compatibility tests
* Fix Core breadcrumb visibility compatibility
* Fix Core breadcrumbs account test isolation
diff --git a/plugins/woocommerce/changelog/fix-core-breadcrumbs-compatibility b/plugins/woocommerce/changelog/fix-core-breadcrumbs-compatibility
new file mode 100644
index 00000000000..f73bcfbc0d4
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-core-breadcrumbs-compatibility
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Align the Core Breadcrumbs block output with WooCommerce Store Breadcrumbs on shop, product, account, archive, and custom taxonomy pages.
diff --git a/plugins/woocommerce/src/Blocks/BlockTypesController.php b/plugins/woocommerce/src/Blocks/BlockTypesController.php
index db68a52932b..e148140a453 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypesController.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypesController.php
@@ -63,8 +63,6 @@ final class BlockTypesController {
add_action( 'woocommerce_login_form_end', array( $this, 'redirect_to_field' ) );
add_filter( 'widget_types_to_hide_from_legacy_widget_block', array( $this, 'hide_legacy_widgets_with_block_equivalent' ) );
add_filter( 'register_block_type_args', array( $this, 'enqueue_block_style_for_classic_themes' ), 10, 2 );
- add_filter( 'block_core_breadcrumbs_post_type_settings', array( $this, 'set_product_breadcrumbs_preferred_taxonomy' ), 10, 3 );
- add_filter( 'block_core_breadcrumbs_items', array( $this, 'apply_woocommerce_breadcrumb_filters' ), 10, 1 );
}
/**
@@ -583,114 +581,4 @@ final class BlockTypesController {
return $args;
}
-
- /**
- * Set the preferred taxonomy and term for the breadcrumbs block on the product post type.
- *
- * This method mimics the behavior of WC_Breadcrumb::add_crumbs_single() to ensure
- * consistent breadcrumb term selection between WooCommerce's legacy breadcrumbs
- * and the Core breadcrumbs block.
- *
- * @param array $settings The settings for the breadcrumbs block.
- * @param string $post_type The post type.
- * @param int $post_id The current post ID.
- * @return array The settings for the breadcrumbs block.
- *
- * @internal
- */
- public function set_product_breadcrumbs_preferred_taxonomy( $settings, $post_type, $post_id = 0 ) {
- if ( ! is_array( $settings ) || 'product' !== $post_type ) {
- return $settings;
- }
-
- $settings['taxonomy'] = 'product_cat';
-
- // If we have a post ID, determine the specific term using WooCommerce's logic.
- if ( ! empty( $post_id ) ) {
- $terms = wc_get_product_terms(
- $post_id,
- 'product_cat',
- /**
- * Filters the arguments used to fetch product terms for breadcrumbs.
- *
- * @since 9.5.0
- *
- * @param array $args Array of arguments for `wc_get_product_terms()`.
- */
- apply_filters(
- 'woocommerce_breadcrumb_product_terms_args',
- array(
- 'orderby' => 'parent',
- 'order' => 'DESC',
- )
- )
- );
-
- if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
- /**
- * Filters the main term used in product breadcrumbs.
- *
- * @since 9.5.0
- *
- * @param \WP_Term $main_term The main term to be used in breadcrumbs.
- * @param \WP_Term[] $terms Array of all product category terms.
- */
- $main_term = apply_filters( 'woocommerce_breadcrumb_main_term', $terms[0], $terms );
-
- if ( $main_term instanceof \WP_Term ) {
- $settings['term'] = $main_term->slug;
- }
- }
- }
-
- return $settings;
- }
-
- /**
- * Apply WooCommerce breadcrumb filters to Core breadcrumbs block items.
- *
- * This bridges the Core breadcrumbs block with WooCommerce's legacy breadcrumb filters,
- * ensuring backward compatibility for sites that have customized breadcrumbs using
- * the `woocommerce_get_breadcrumb` filter.
- *
- * @param array $items Array of breadcrumb items from Core.
- * @return array Modified breadcrumb items.
- *
- * @internal
- */
- public function apply_woocommerce_breadcrumb_filters( $items ) {
- // Convert Core format to WooCommerce format.
- // Core: array( 'url' => '...', 'label' => '...' )
- // Woo: array( 'label', 'url' ).
- $wc_crumbs = array_map(
- function ( $item ) {
- return array(
- $item['label'] ?? '',
- $item['url'] ?? '',
- );
- },
- $items
- );
-
- /**
- * Filters the breadcrumb trail array.
- *
- * @since 2.3.0
- *
- * @param array $crumbs The breadcrumb trail.
- * @param \WC_Breadcrumb|null $breadcrumb The breadcrumb object (null when called from Core block).
- */
- $wc_crumbs = apply_filters( 'woocommerce_get_breadcrumb', $wc_crumbs, null );
-
- // Convert back to Core format.
- return array_map(
- function ( $crumb ) {
- return array(
- 'label' => $crumb[0] ?? '',
- 'url' => $crumb[1] ?? '',
- );
- },
- $wc_crumbs
- );
- }
}
diff --git a/plugins/woocommerce/src/Blocks/CoreBreadcrumbsCompatibility.php b/plugins/woocommerce/src/Blocks/CoreBreadcrumbsCompatibility.php
new file mode 100644
index 00000000000..8a750e28f0e
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/CoreBreadcrumbsCompatibility.php
@@ -0,0 +1,647 @@
+<?php
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Blocks;
+
+/**
+ * Adds WooCommerce compatibility behavior to the Core Breadcrumbs block.
+ *
+ * @internal
+ */
+final class CoreBreadcrumbsCompatibility {
+
+ /**
+ * Whether the compatibility hooks have been initialized.
+ *
+ * @var bool
+ */
+ private $is_initialized = false;
+
+ /**
+ * Initialize Core Breadcrumbs compatibility hooks.
+ *
+ * @internal
+ */
+ public function init(): void {
+ if ( $this->is_initialized ) {
+ return;
+ }
+
+ add_filter( 'block_core_breadcrumbs_post_type_settings', array( $this, 'set_product_breadcrumbs_preferred_taxonomy' ), 10, 3 );
+ add_filter( 'block_core_breadcrumbs_items', array( $this, 'apply_woocommerce_breadcrumb_filters' ), 10, 1 );
+
+ $this->is_initialized = true;
+ }
+
+ /*
+ * Compatibility methods.
+ */
+
+ /**
+ * Set the preferred taxonomy and term for the breadcrumbs block on the product post type.
+ *
+ * This method mimics the behavior of WC_Breadcrumb::add_crumbs_single() to ensure
+ * consistent breadcrumb term selection between WooCommerce's legacy breadcrumbs
+ * and the Core breadcrumbs block.
+ *
+ * @internal
+ *
+ * @param array $settings The settings for the breadcrumbs block.
+ * @param string $post_type The post type.
+ * @param int $post_id The current post ID.
+ * @return array The settings for the breadcrumbs block.
+ */
+ public function set_product_breadcrumbs_preferred_taxonomy( $settings, $post_type, $post_id = 0 ) {
+ if ( ! is_array( $settings ) || 'product' !== $post_type ) {
+ return $settings;
+ }
+
+ $settings['taxonomy'] = 'product_cat';
+ $post_id = (int) $post_id;
+
+ if ( ! $post_id ) {
+ return $settings;
+ }
+
+ $terms = wc_get_product_terms(
+ $post_id,
+ 'product_cat',
+ /**
+ * Filters the arguments used to fetch product terms for breadcrumbs.
+ *
+ * @since 9.5.0
+ *
+ * @param array $args Array of arguments for `wc_get_product_terms()`.
+ */
+ apply_filters(
+ 'woocommerce_breadcrumb_product_terms_args',
+ array(
+ 'orderby' => 'parent',
+ 'order' => 'DESC',
+ )
+ )
+ );
+
+ if ( empty( $terms ) || is_wp_error( $terms ) ) {
+ return $settings;
+ }
+
+ /**
+ * Filters the main term used in product breadcrumbs.
+ *
+ * @since 9.5.0
+ *
+ * @param \WP_Term $main_term The main term to be used in breadcrumbs.
+ * @param \WP_Term[] $terms Array of all product category terms.
+ */
+ $main_term = apply_filters( 'woocommerce_breadcrumb_main_term', $terms[0], $terms );
+
+ if ( $main_term instanceof \WP_Term ) {
+ $settings['term'] = $main_term->slug;
+ }
+
+ return $settings;
+ }
+
+ /**
+ * Apply WooCommerce breadcrumb filters to Core breadcrumbs block items.
+ *
+ * This bridges the Core breadcrumbs block with WooCommerce's legacy breadcrumb filters,
+ * ensuring backward compatibility for sites that have customized breadcrumbs using
+ * the `woocommerce_get_breadcrumb` filter.
+ *
+ * @internal
+ *
+ * @param array $items Array of breadcrumb items from Core.
+ * @return array Modified breadcrumb items.
+ */
+ public function apply_woocommerce_breadcrumb_filters( $items ) {
+ if ( ! is_array( $items ) ) {
+ return $items;
+ }
+
+ $items = $this->apply_woocommerce_core_breadcrumb_adjustments( $items );
+
+ if ( ! has_filter( 'woocommerce_get_breadcrumb' ) ) {
+ return $items;
+ }
+
+ // Convert Core format to WooCommerce format.
+ // Core: array( 'url' => '...', 'label' => '...' )
+ // Woo: array( 'label', 'url' ).
+ $wc_crumbs = array_map(
+ function ( $item ) {
+ return array(
+ $item['label'] ?? '',
+ $item['url'] ?? '',
+ );
+ },
+ $items
+ );
+
+ /**
+ * Filters the breadcrumb trail array.
+ *
+ * @since 2.3.0
+ *
+ * @param array $crumbs The breadcrumb trail.
+ * @param \WC_Breadcrumb|null $breadcrumb The breadcrumb object (null when called from Core block).
+ */
+ $wc_crumbs = apply_filters( 'woocommerce_get_breadcrumb', $wc_crumbs, null );
+
+ $core_items = array();
+
+ foreach ( $wc_crumbs as $index => $crumb ) {
+ $item = isset( $items[ $index ] ) && is_array( $items[ $index ] ) ? $items[ $index ] : array();
+ $label = is_array( $crumb ) ? ( $crumb[0] ?? '' ) : '';
+ $url = is_array( $crumb ) ? ( $crumb[1] ?? '' ) : '';
+
+ $item['label'] = $label;
+
+ if ( $url ) {
+ $item['url'] = $url;
+ } else {
+ unset( $item['url'] );
+ }
+
+ $core_items[] = $item;
+ }
+
+ return $core_items;
+ }
+
+ /**
+ * Apply WooCommerce breadcrumb behavior to Core breadcrumbs.
+ *
+ * @param array $items Array of breadcrumb items from Core.
+ * @return array Modified breadcrumb items.
+ */
+ private function apply_woocommerce_core_breadcrumb_adjustments( $items ) {
+ if ( ! is_array( $items ) ) {
+ return $items;
+ }
+
+ $items = $this->replace_product_archive_breadcrumb_label( $items );
+ $items = $this->prepend_shop_page_to_product_taxonomy_breadcrumbs( $items );
+ $items = $this->prepend_taxonomy_label_to_product_taxonomy_breadcrumbs( $items );
+ $items = $this->prepend_shop_page_to_product_search_breadcrumbs( $items );
+ $items = $this->replace_product_tag_breadcrumb_label( $items );
+ $items = $this->replace_search_breadcrumb_label( $items );
+ $items = $this->prepend_my_account_page_to_endpoint_breadcrumbs( $items );
+ $items = $this->apply_home_breadcrumb_url_filter( $items );
+
+ return $items;
+ }
+
+ /**
+ * Apply WooCommerce's Home breadcrumb URL filter.
+ *
+ * @param array $items Array of breadcrumb items from Core.
+ * @return array Modified breadcrumb items.
+ */
+ private function apply_home_breadcrumb_url_filter( $items ) {
+ if ( ! has_filter( 'woocommerce_breadcrumb_home_url' ) ) {
+ return $items;
+ }
+
+ $home_index = $this->get_breadcrumb_item_index_by_url( $items, home_url( '/' ) );
+
+ if ( null === $home_index ) {
+ return $items;
+ }
+
+ $default_home_url = home_url();
+
+ /**
+ * Filters the Home breadcrumb URL.
+ *
+ * @param string $url The Home breadcrumb URL.
+ *
+ * @since 2.3.0
+ */
+ $home_url = apply_filters( 'woocommerce_breadcrumb_home_url', $default_home_url );
+
+ $items[ $home_index ]['url'] = is_string( $home_url ) ? $home_url : $default_home_url;
+
+ return $items;
+ }
+
+ /**
+ * Replace product archive breadcrumb labels with the WooCommerce shop page title.
+ *
+ * @param array $items Array of breadcrumb items from Core.
+ * @return array Modified breadcrumb items.
+ */
+ private function replace_product_archive_breadcrumb_label( $items ) {
+ $shop_url = get_post_type_archive_link( 'product' );
+
+ if ( ! $shop_url || null === $this->get_breadcrumb_item_index_by_url( $items, $shop_url ) ) {
+ return $items;
+ }
+
+ $shop_page_item = $this->get_shop_page_breadcrumb_item( $shop_url );
+
+ if ( empty( $shop_page_item ) ) {
+ return $items;
+ }
+
+ foreach ( $items as $index => $item ) {
+ if ( self::are_breadcrumb_urls_equal( $item['url'] ?? '', $shop_page_item['url'] ) ) {
+ $items[ $index ]['label'] = $shop_page_item['label'];
+ }
+ }
+
+ return $items;
+ }
+
+ /**
+ * Prepend the shop page to product taxonomy breadcrumbs.
+ *
+ * @param array $items Array of breadcrumb items from Core.
+ * @return array Modified breadcrumb items.
+ */
+ private function prepend_shop_page_to_product_taxonomy_breadcrumbs( $items ) {
+ if ( ! ( is_product_category() || is_product_tag() ) ) {
+ return $items;
+ }
+
+ $permalinks = wc_get_permalink_structure();
+ $shop_page = $this->get_woocommerce_page_post( 'shop' );
+
+ if (
+ ! $shop_page ||
+ ! isset( $permalinks['product_base'] ) ||
+ ! strstr( $permalinks['product_base'], '/' . $shop_page->post_name ) ||
+ intval( get_option( 'page_on_front' ) ) === $shop_page->ID
+ ) {
+ return $items;
+ }
+
+ return $this->prepend_shop_page_to_breadcrumbs( $items );
+ }
+
+ /**
+ * Prepend taxonomy labels to product taxonomy breadcrumbs.
+ *
+ * @param array $items Array of breadcrumb items from Core.
+ * @return array Modified breadcrumb items.
+ */
+ private function prepend_taxonomy_label_to_product_taxonomy_breadcrumbs( $items ) {
+ if ( ! is_product_taxonomy() || empty( $items ) ) {
+ return $items;
+ }
+
+ $current_term = $this->get_queried_term();
+
+ if ( ! $current_term || in_array( $current_term->taxonomy, array( 'product_brand', 'product_cat', 'product_tag' ), true ) ) {
+ return $items;
+ }
+
+ $taxonomy = get_taxonomy( $current_term->taxonomy );
+
+ if ( ! $taxonomy || ! $taxonomy->labels->name ) {
+ return $items;
+ }
+
+ $insert_index = $this->get_first_breadcrumb_insert_index( $items );
+
+ if ( isset( $items[ $insert_index ]['label'] ) && $taxonomy->labels->name === $items[ $insert_index ]['label'] ) {
+ return $items;
+ }
+
+ return $this->insert_parent_breadcrumb_item(
+ $items,
+ array(
+ 'label' => $taxonomy->labels->name,
+ )
+ );
+ }
+
+ /**
+ * Prepend the shop page to product search breadcrumbs.
+ *
+ * @param array $items Array of breadcrumb items from Core.
+ * @return array Modified breadcrumb items.
+ */
+ private function prepend_shop_page_to_product_search_breadcrumbs( $items ) {
+ if ( ! $this->is_product_search() || intval( get_option( 'page_on_front' ) ) === wc_get_page_id( 'shop' ) ) {
+ return $items;
+ }
+
+ return $this->prepend_shop_page_to_breadcrumbs( $items );
+ }
+
+ /**
+ * Replace Core's search breadcrumb label with WooCommerce's search label.
+ *
+ * @param array $items Array of breadcrumb items from Core.
+ * @return array Modified breadcrumb items.
+ */
+ private function replace_search_breadcrumb_label( $items ) {
+ if ( ! is_search() || empty( $items ) ) {
+ return $items;
+ }
+
+ $search_url = (int) get_query_var( 'paged' ) > 1 ? get_pagenum_link( 1 ) : '';
+
+ /* translators: %s: search term */
+ return $this->replace_current_archive_breadcrumb_label( $items, sprintf( __( 'Search results for “%s”', 'woocommerce' ), get_search_query() ), $search_url );
+ }
+
+ /**
+ * Replace product tag breadcrumb labels with WooCommerce's tag archive label.
+ *
+ * @param array $items Array of breadcrumb items from Core.
+ * @return array Modified breadcrumb items.
+ */
+ private function replace_product_tag_breadcrumb_label( $items ) {
+ if ( ! is_product_tag() || empty( $items ) ) {
+ return $items;
+ }
+
+ $current_term = $this->get_queried_term();
+
+ if ( ! $current_term ) {
+ return $items;
+ }
+
+ $tag_link = get_term_link( $current_term, 'product_tag' );
+
+ if ( is_wp_error( $tag_link ) ) {
+ $tag_link = '';
+ }
+
+ /* translators: %s: product tag */
+ return $this->replace_current_archive_breadcrumb_label( $items, sprintf( __( 'Products tagged “%s”', 'woocommerce' ), $current_term->name ), $tag_link );
+ }
+
+ /**
+ * Prepend the My Account page to account endpoint breadcrumbs.
+ *
+ * @param array $items Array of breadcrumb items from Core.
+ * @return array Modified breadcrumb items.
+ */
+ private function prepend_my_account_page_to_endpoint_breadcrumbs( $items ) {
+ if ( ! is_wc_endpoint_url() || ! is_account_page() ) {
+ return $items;
+ }
+
+ $my_account_page = $this->get_woocommerce_page_post( 'myaccount' );
+ $my_account_url = $my_account_page ? get_permalink( $my_account_page ) : '';
+
+ if ( ! $my_account_page || ! $my_account_url ) {
+ return $items;
+ }
+
+ $woocommerce = WC();
+
+ if ( $woocommerce->query instanceof \WC_Query ) {
+ $endpoint = $woocommerce->query->get_current_endpoint();
+ $action = isset( $_GET['action'] ) ? sanitize_text_field( wp_unslash( $_GET['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Used only to select the breadcrumb label.
+ $endpoint_title = $endpoint ? $woocommerce->query->get_endpoint_title( $endpoint, $action ) : '';
+
+ if ( $endpoint_title ) {
+ $items = $this->replace_current_archive_breadcrumb_label( $items, $endpoint_title );
+ }
+ }
+
+ return $this->insert_parent_breadcrumb_item_if_missing_url(
+ $items,
+ array(
+ 'label' => $my_account_page->post_title,
+ 'url' => $my_account_url,
+ )
+ );
+ }
+
+ /*
+ * Utility methods.
+ */
+
+ /**
+ * Check whether the current request is a product search.
+ *
+ * @return bool Whether the current request is a product search.
+ */
+ private function is_product_search(): bool {
+ if ( ! is_search() ) {
+ return false;
+ }
+
+ $post_type = get_query_var( 'post_type' );
+
+ if ( is_array( $post_type ) ) {
+ return in_array( 'product', $post_type, true );
+ }
+
+ return 'product' === $post_type || is_shop();
+ }
+
+ /**
+ * Prepend the shop page to breadcrumb items.
+ *
+ * @param array $items Array of breadcrumb items from Core.
+ * @return array Modified breadcrumb items.
+ */
+ private function prepend_shop_page_to_breadcrumbs( $items ) {
+ $shop_page_item = $this->get_shop_page_breadcrumb_item();
+
+ if ( empty( $shop_page_item ) ) {
+ return $items;
+ }
+
+ $shop_page_index = $this->get_breadcrumb_item_index_by_url( $items, $shop_page_item['url'] );
+
+ if ( null !== $shop_page_index ) {
+ return $this->replace_breadcrumb_label_at_index( $items, $shop_page_index, $shop_page_item['label'] );
+ }
+
+ return $this->insert_parent_breadcrumb_item_if_missing_url( $items, $shop_page_item );
+ }
+
+ /**
+ * Get the shop page breadcrumb item.
+ *
+ * @param string $shop_url Shop archive URL.
+ * @return array|null Shop page breadcrumb item.
+ */
+ private function get_shop_page_breadcrumb_item( $shop_url = '' ) {
+ $shop_page = $this->get_woocommerce_page_post( 'shop' );
+ $shop_url = $shop_url ? $shop_url : (
+ $shop_page ? get_permalink( $shop_page ) : get_post_type_archive_link( 'product' )
+ );
+ $shop_label = $shop_page ? get_the_title( $shop_page ) : '';
+
+ if ( ! $shop_label ) {
+ $product_post_type = get_post_type_object( 'product' );
+ $shop_label = $product_post_type ? $product_post_type->labels->name : '';
+ }
+
+ if ( ! $shop_url || ! $shop_label ) {
+ return null;
+ }
+
+ return array(
+ 'label' => $shop_label,
+ 'url' => $shop_url,
+ );
+ }
+
+ /**
+ * Get a WooCommerce page post.
+ *
+ * @param string $page_name WooCommerce page name.
+ * @return \WP_Post|null WooCommerce page post.
+ */
+ private function get_woocommerce_page_post( string $page_name ) {
+ $page_id = wc_get_page_id( $page_name );
+
+ return $page_id > 0 ? get_post( $page_id ) : null;
+ }
+
+ /**
+ * Get the queried term.
+ *
+ * @return \WP_Term|null Queried term.
+ */
+ private function get_queried_term() {
+ $queried_object = $GLOBALS['wp_query']->get_queried_object();
+
+ return $queried_object instanceof \WP_Term ? $queried_object : null;
+ }
+
+ /**
+ * Replace the current archive breadcrumb label.
+ *
+ * @param array $items Array of breadcrumb items from Core.
+ * @param string $label Replacement label.
+ * @param string $archive_url Archive URL.
+ * @return array Modified breadcrumb items.
+ */
+ private function replace_current_archive_breadcrumb_label( $items, $label, $archive_url = '' ) {
+ $item_index = $this->get_breadcrumb_item_index_by_url( $items, $archive_url );
+
+ if ( null === $item_index ) {
+ $item_keys = array_keys( $items );
+
+ if ( empty( $item_keys ) ) {
+ return $items;
+ }
+
+ $item_index = end( $item_keys );
+ $paged = (int) get_query_var( 'paged' );
+
+ if ( $paged > 1 && count( $item_keys ) > 1 && isset( $items[ $item_index ]['label'] ) ) {
+ $pagination_label = sprintf(
+ /* translators: %s: page number */
+ __( 'Page %s', 'default' ), // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- Match the Core Breadcrumbs block pagination label.
+ number_format_i18n( $paged )
+ );
+
+ if ( $pagination_label === (string) $items[ $item_index ]['label'] ) {
+ $item_index = $item_keys[ count( $item_keys ) - 2 ];
+ }
+ }
+
+ if ( ! empty( $items[ $item_index ]['url'] ) ) {
+ return $items;
+ }
+ }
+
+ return $this->replace_breadcrumb_label_at_index( $items, $item_index, $label );
+ }
+
+ /**
+ * Replace a breadcrumb label at an index.
+ *
+ * @param array $items Array of breadcrumb items from Core.
+ * @param int|string|null $index Breadcrumb item index.
+ * @param string $label Replacement label.
+ * @return array Modified breadcrumb items.
+ */
+ private function replace_breadcrumb_label_at_index( $items, $index, $label ) {
+ if ( null === $index || ! isset( $items[ $index ] ) ) {
+ return $items;
+ }
+
+ $items[ $index ]['label'] = $label;
+
+ return $items;
+ }
+
+ /**
+ * Insert a parent breadcrumb item if its URL is not already present.
+ *
+ * @param array $items Array of breadcrumb items from Core.
+ * @param array $item Breadcrumb item to insert.
+ * @return array Modified breadcrumb items.
+ */
+ private function insert_parent_breadcrumb_item_if_missing_url( $items, $item ) {
+ if ( empty( $item['url'] ) || null !== $this->get_breadcrumb_item_index_by_url( $items, $item['url'] ) ) {
+ return $items;
+ }
+
+ return $this->insert_parent_breadcrumb_item( $items, $item );
+ }
+
+ /**
+ * Insert a parent breadcrumb item after the home item.
+ *
+ * @param array $items Array of breadcrumb items from Core.
+ * @param array $item Breadcrumb item to insert.
+ * @return array Modified breadcrumb items.
+ */
+ private function insert_parent_breadcrumb_item( $items, $item ) {
+ array_splice( $items, $this->get_first_breadcrumb_insert_index( $items ), 0, array( $item ) );
+
+ return $items;
+ }
+
+ /**
+ * Get the first breadcrumb item index matching a URL.
+ *
+ * @param array $items Array of breadcrumb items from Core.
+ * @param string $url URL to find.
+ * @return int|string|null Breadcrumb item index.
+ */
+ private function get_breadcrumb_item_index_by_url( $items, $url ) {
+ if ( ! $url ) {
+ return null;
+ }
+
+ foreach ( $items as $index => $item ) {
+ if ( self::are_breadcrumb_urls_equal( $item['url'] ?? '', $url ) ) {
+ return $index;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the index where WooCommerce should insert parent breadcrumb items.
+ *
+ * @param array $items Array of breadcrumb items from Core.
+ * @return int Breadcrumb insertion index.
+ */
+ private function get_first_breadcrumb_insert_index( $items ) {
+ if ( empty( $items ) ) {
+ return 0;
+ }
+
+ $first_item = reset( $items );
+
+ return self::are_breadcrumb_urls_equal( $first_item['url'] ?? '', home_url( '/' ) ) ? 1 : 0;
+ }
+
+ /**
+ * Check whether two breadcrumb URLs are equivalent.
+ *
+ * @param string $first_url First URL.
+ * @param string $second_url Second URL.
+ * @return bool Whether the URLs are equivalent.
+ */
+ private static function are_breadcrumb_urls_equal( $first_url, $second_url ) {
+ return untrailingslashit( (string) $first_url ) === untrailingslashit( (string) $second_url );
+ }
+}
diff --git a/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php b/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php
index 9975adbf51b..86609eb3c24 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\Blocks\BlockPatterns;
use Automattic\WooCommerce\Blocks\BlockTemplatesRegistry;
use Automattic\WooCommerce\Blocks\BlockTemplatesController;
use Automattic\WooCommerce\Blocks\BlockTypesController;
+use Automattic\WooCommerce\Blocks\CoreBreadcrumbsCompatibility;
use Automattic\WooCommerce\Blocks\DependencyDetection;
use Automattic\WooCommerce\Blocks\Patterns\PatternRegistry;
use Automattic\WooCommerce\Blocks\Patterns\PTKClient;
@@ -149,6 +150,7 @@ class Bootstrap {
if ( ( new BlockRegistrationContext() )->should_register() ) {
$this->container->get( BlockPatterns::class );
$this->container->get( BlockTypesController::class );
+ $this->container->get( CoreBreadcrumbsCompatibility::class )->init();
}
$this->container->get( ClassicTemplatesCompatibility::class );
$this->container->get( Notices::class )->init();
@@ -245,6 +247,12 @@ class Bootstrap {
return new BlockTypesController( $asset_api, $asset_data_registry );
}
);
+ $this->container->register(
+ CoreBreadcrumbsCompatibility::class,
+ function () {
+ return new CoreBreadcrumbsCompatibility();
+ }
+ );
$this->container->register(
ClassicTemplatesCompatibility::class,
function ( Container $container ) {
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypesController.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypesController.php
index b75082a26fd..6edfde59d5f 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypesController.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypesController.php
@@ -5,13 +5,14 @@ namespace Automattic\WooCommerce\Tests\Blocks;
use Automattic\WooCommerce\Blocks\Assets\Api;
use Automattic\WooCommerce\Blocks\BlockTypesController as TestedBlockTypesController;
-use Automattic\WooCommerce\Tests\Blocks\Mocks\AssetDataRegistryMock;
use Automattic\WooCommerce\Blocks\Package;
+use Automattic\WooCommerce\Tests\Blocks\Mocks\AssetDataRegistryMock;
+use WC_Unit_Test_Case;
/**
- * Unit tests for the PatternRegistry class.
+ * Unit tests for the BlockTypesController class.
*/
-class BlockTypesController extends \WP_UnitTestCase {
+class BlockTypesController extends WC_Unit_Test_Case {
/**
* Holds the BlockTypesController under test.
@@ -26,8 +27,9 @@ class BlockTypesController extends \WP_UnitTestCase {
* @return void
* @throws \Exception If there is no dependency for the given identifier in the container the setup will fail.
*/
- protected function setUp(): void {
+ public function setUp(): void {
parent::setUp();
+
$this->block_types_controller = new TestedBlockTypesController(
Package::container()->get( Api::class ),
new AssetDataRegistryMock( Package::container()->get( API::class ) )
@@ -35,12 +37,9 @@ class BlockTypesController extends \WP_UnitTestCase {
}
/**
- * Register 3 blocks, one will be allowed by full name, one by namespace,and one because it has a parent with a
- * woocommerce namespace.
- *
- * @return void
+ * @testdox Should identify blocks that should have data attributes.
*/
- public function test_block_should_have_data_attributes() {
+ public function test_block_should_have_data_attributes(): void {
// A block that will not be allowed data attributes.
register_block_type(
diff --git a/plugins/woocommerce/tests/php/src/Blocks/CoreBreadcrumbsCompatibilityTest.php b/plugins/woocommerce/tests/php/src/Blocks/CoreBreadcrumbsCompatibilityTest.php
new file mode 100644
index 00000000000..655dd0da9ed
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/CoreBreadcrumbsCompatibilityTest.php
@@ -0,0 +1,870 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks;
+
+use Automattic\WooCommerce\Blocks\CoreBreadcrumbsCompatibility;
+use Automattic\WooCommerce\Blocks\Domain\Bootstrap;
+use Automattic\WooCommerce\Blocks\Domain\Package as BlocksPackage;
+use Automattic\WooCommerce\Blocks\Registry\Container;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for the CoreBreadcrumbsCompatibility class.
+ */
+class CoreBreadcrumbsCompatibilityTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var CoreBreadcrumbsCompatibility
+ */
+ private CoreBreadcrumbsCompatibility $sut;
+
+ /**
+ * Original shop page ID.
+ *
+ * @var mixed
+ */
+ private $original_shop_page_id;
+
+ /**
+ * Original My Account page ID.
+ *
+ * @var mixed
+ */
+ private $original_myaccount_page_id;
+
+ /**
+ * Original WooCommerce permalinks.
+ *
+ * @var mixed
+ */
+ private $original_woocommerce_permalinks;
+
+ /**
+ * Original product post type archive setting.
+ *
+ * @var mixed
+ */
+ private $original_product_has_archive;
+
+ /**
+ * Original WooCommerce query object.
+ *
+ * @var \WC_Query
+ */
+ private \WC_Query $original_woocommerce_query;
+
+ /**
+ * Original request query vars.
+ *
+ * @var array
+ */
+ private $original_wp_query_vars;
+
+ /**
+ * Shop page ID.
+ *
+ * @var int
+ */
+ private $shop_page_id;
+
+ /**
+ * My Account page ID.
+ *
+ * @var int
+ */
+ private $my_account_page_id;
+
+ /**
+ * Sets up test fixtures.
+ *
+ * @return void
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ global $wp;
+
+ $this->sut = new CoreBreadcrumbsCompatibility();
+
+ $product_post_type = get_post_type_object( 'product' );
+
+ $this->original_shop_page_id = get_option( 'woocommerce_shop_page_id' );
+ $this->original_myaccount_page_id = get_option( 'woocommerce_myaccount_page_id' );
+ $this->original_woocommerce_permalinks = get_option( 'woocommerce_permalinks' );
+ $this->original_product_has_archive = $product_post_type ? $product_post_type->has_archive : null;
+ $this->original_woocommerce_query = WC()->query;
+ $this->original_wp_query_vars = $wp instanceof \WP && is_array( $wp->query_vars ) ? $wp->query_vars : array();
+ $this->shop_page_id = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ 'post_title' => 'Catalog',
+ 'post_name' => 'shop',
+ )
+ );
+ $this->my_account_page_id = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ 'post_title' => 'My account',
+ 'post_name' => 'my-account',
+ )
+ );
+ $woocommerce_permalinks = is_array( $this->original_woocommerce_permalinks ) ? $this->original_woocommerce_permalinks : array();
+ $woocommerce_permalinks['product_base'] = '/shop';
+
+ update_option( 'woocommerce_shop_page_id', $this->shop_page_id );
+ update_option( 'woocommerce_myaccount_page_id', $this->my_account_page_id );
+ update_option( 'woocommerce_permalinks', $woocommerce_permalinks );
+
+ if ( $product_post_type ) {
+ $product_post_type->has_archive = get_page_uri( $this->shop_page_id );
+ }
+ }
+
+ /**
+ * Tear down test fixtures.
+ *
+ * @return void
+ */
+ public function tearDown(): void {
+ global $wp;
+
+ remove_filter( 'block_core_breadcrumbs_post_type_settings', array( $this->sut, 'set_product_breadcrumbs_preferred_taxonomy' ), 10 );
+ remove_filter( 'block_core_breadcrumbs_items', array( $this->sut, 'apply_woocommerce_breadcrumb_filters' ), 10 );
+ remove_filter( 'woocommerce_is_account_page', '__return_true' );
+
+ update_option( 'woocommerce_shop_page_id', $this->original_shop_page_id );
+ update_option( 'woocommerce_myaccount_page_id', $this->original_myaccount_page_id );
+ update_option( 'woocommerce_permalinks', $this->original_woocommerce_permalinks );
+
+ $product_post_type = get_post_type_object( 'product' );
+ if ( $product_post_type ) {
+ $product_post_type->has_archive = $this->original_product_has_archive;
+ }
+
+ if ( $wp instanceof \WP ) {
+ $wp->query_vars = $this->original_wp_query_vars;
+ }
+
+ WC()->query = $this->original_woocommerce_query;
+
+ if ( taxonomy_exists( 'pa_test_color' ) ) {
+ unregister_taxonomy( 'pa_test_color' );
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Should register Core Breadcrumbs compatibility filters.
+ */
+ public function test_init_registers_core_breadcrumbs_filters(): void {
+ $this->sut->init();
+ $this->sut->init();
+
+ $this->assertSame( 10, has_filter( 'block_core_breadcrumbs_post_type_settings', array( $this->sut, 'set_product_breadcrumbs_preferred_taxonomy' ) ), 'Product breadcrumb settings filter should be registered.' );
+ $this->assertSame( 10, has_filter( 'block_core_breadcrumbs_items', array( $this->sut, 'apply_woocommerce_breadcrumb_filters' ) ), 'Breadcrumb items filter should be registered.' );
+ }
+
+ /**
+ * @testdox Should register Core Breadcrumbs compatibility filters through the Blocks bootstrap.
+ */
+ public function test_blocks_bootstrap_registers_core_breadcrumbs_filters(): void {
+ $container = new Container();
+ $container->register(
+ BlocksPackage::class,
+ function () {
+ return new BlocksPackage( 'test', dirname( WC_PLUGIN_FILE ) );
+ }
+ );
+
+ new Bootstrap( $container );
+ $compatibility = $container->get( CoreBreadcrumbsCompatibility::class );
+
+ try {
+ $this->assertSame( 10, has_filter( 'block_core_breadcrumbs_post_type_settings', array( $compatibility, 'set_product_breadcrumbs_preferred_taxonomy' ) ), 'Bootstrap should register the product breadcrumb settings filter.' );
+ $this->assertSame( 10, has_filter( 'block_core_breadcrumbs_items', array( $compatibility, 'apply_woocommerce_breadcrumb_filters' ) ), 'Bootstrap should register the breadcrumb items filter.' );
+ } finally {
+ remove_filter( 'block_core_breadcrumbs_post_type_settings', array( $compatibility, 'set_product_breadcrumbs_preferred_taxonomy' ), 10 );
+ remove_filter( 'block_core_breadcrumbs_items', array( $compatibility, 'apply_woocommerce_breadcrumb_filters' ), 10 );
+ }
+ }
+
+ /**
+ * @testdox Should set product category as the preferred product breadcrumb taxonomy.
+ */
+ public function test_sets_product_breadcrumbs_preferred_taxonomy(): void {
+ $category_id = $this->create_term( 'Shirts', 'product_cat', array( 'slug' => 'shirts' ) );
+ $product_id = self::factory()->post->create(
+ array(
+ 'post_type' => 'product',
+ )
+ );
+ wp_set_post_terms( $product_id, array( $category_id ), 'product_cat' );
+
+ $result = $this->sut->set_product_breadcrumbs_preferred_taxonomy( array(), 'product', $product_id );
+
+ $this->assertSame( 'product_cat', $result['taxonomy'], 'Products should prefer product categories.' );
+ $this->assertSame( 'shirts', $result['term'], 'Products should prefer WooCommerce-selected product category terms.' );
+ }
+
+ /**
+ * @testdox Should apply the WooCommerce Home breadcrumb URL filter.
+ */
+ public function test_core_breadcrumbs_apply_home_breadcrumb_url_filter(): void {
+ $callback = function () {
+ return home_url( '/storefront/' );
+ };
+ add_filter( 'woocommerce_breadcrumb_home_url', $callback );
+ $this->go_to( $this->get_product_archive_url() );
+ $this->set_product_archive_request();
+
+ try {
+ $result = $this->apply_core_breadcrumb_filters(
+ $this->get_breadcrumb_item( 'Current page' )
+ );
+ } finally {
+ remove_filter( 'woocommerce_breadcrumb_home_url', $callback );
+ }
+
+ $this->assertSame(
+ array(
+ $this->get_home_breadcrumb_item( home_url( '/storefront/' ) ),
+ $this->get_breadcrumb_item( 'Current page' ),
+ ),
+ $result,
+ 'Core Breadcrumbs should use the filtered WooCommerce Home breadcrumb URL.'
+ );
+ }
+
+ /**
+ * @testdox Should apply the WooCommerce Home breadcrumb URL filter outside WooCommerce contexts.
+ */
+ public function test_core_breadcrumbs_apply_home_breadcrumb_url_filter_outside_woocommerce_contexts(): void {
+ $callback = function () {
+ return home_url( '/storefront/' );
+ };
+ add_filter( 'woocommerce_breadcrumb_home_url', $callback );
+ $this->go_to( '/?s=breadcrumb' );
+
+ try {
+ $result = $this->apply_core_breadcrumb_filters(
+ $this->get_breadcrumb_item( 'Search results for: "breadcrumb"' )
+ );
+ } finally {
+ remove_filter( 'woocommerce_breadcrumb_home_url', $callback );
+ }
+
+ $this->assertSame(
+ home_url( '/storefront/' ),
+ $result[0]['url'],
+ 'Core Breadcrumbs should use the filtered WooCommerce Home breadcrumb URL outside WooCommerce contexts.'
+ );
+ }
+
+ /**
+ * @testdox Should use the default Home breadcrumb URL when the WooCommerce Home URL filter returns a non-string.
+ */
+ public function test_core_breadcrumbs_use_default_home_breadcrumb_url_for_non_string_filter_value(): void {
+ $callback = function () {
+ return array();
+ };
+ add_filter( 'woocommerce_breadcrumb_home_url', $callback );
+ $this->go_to( $this->get_product_archive_url() );
+ $this->set_product_archive_request();
+
+ try {
+ $result = $this->apply_core_breadcrumb_filters(
+ $this->get_breadcrumb_item( 'Current page' )
+ );
+ } finally {
+ remove_filter( 'woocommerce_breadcrumb_home_url', $callback );
+ }
+
+ $this->assertSame(
+ home_url(),
+ $result[0]['url'],
+ 'Core Breadcrumbs should fall back to the default WooCommerce Home breadcrumb URL.'
+ );
+ }
+
+ /**
+ * @testdox Should use the shop page title for product archive breadcrumbs.
+ */
+ public function test_core_breadcrumbs_use_shop_page_title_for_product_archive_item(): void {
+ $product_item = $this->get_breadcrumb_item( 'Logo Tee' );
+ $this->go_to( $this->get_product_archive_url() );
+ $this->set_product_archive_request();
+
+ $result = $this->apply_core_breadcrumb_filters(
+ $this->get_breadcrumb_item( 'All Products', $this->get_product_archive_url() ),
+ $product_item
+ );
+
+ $this->assertSame(
+ array( 'Home', 'Catalog', 'Logo Tee' ),
+ $this->get_breadcrumb_labels( $result ),
+ 'Product archive breadcrumb should use the Shop page title.'
+ );
+ $this->assertSame(
+ $this->get_core_breadcrumb_items(
+ $this->get_breadcrumb_item( 'Catalog', $this->get_product_archive_url() ),
+ $product_item
+ ),
+ $result,
+ 'Product archive breadcrumb should preserve Core item shape while updating the label.'
+ );
+ }
+
+ /**
+ * @testdox Should prepend the shop page to product category breadcrumbs.
+ */
+ public function test_core_breadcrumbs_prepend_shop_page_to_product_category_items(): void {
+ $category_id = $this->create_term( 'Shirts', 'product_cat', array( 'slug' => 'shirts' ) );
+ $this->go_to( get_term_link( $category_id, 'product_cat' ) );
+
+ $this->assert_core_breadcrumb_labels(
+ array( 'Home', 'Catalog', 'Shirts' ),
+ 'Product category breadcrumbs should include the Shop page crumb.',
+ $this->get_breadcrumb_item( 'Shirts' )
+ );
+ }
+
+ /**
+ * @testdox Should not duplicate the shop page when Core already includes it.
+ */
+ public function test_core_breadcrumbs_do_not_duplicate_existing_shop_page_item(): void {
+ $category_id = $this->create_term( 'Shirts', 'product_cat', array( 'slug' => 'shirts' ) );
+ $this->go_to( get_term_link( $category_id, 'product_cat' ) );
+
+ $shop_url = untrailingslashit( $this->get_shop_page_url() );
+ $shirt_item = $this->get_breadcrumb_item( 'Shirts' );
+ $result = $this->apply_core_breadcrumb_filters(
+ $this->get_breadcrumb_item( 'Existing catalog', $shop_url ),
+ $shirt_item
+ );
+
+ $this->assertSame(
+ $this->get_core_breadcrumb_items(
+ $this->get_breadcrumb_item( 'Catalog', $shop_url ),
+ $shirt_item
+ ),
+ $result,
+ 'Product category breadcrumbs should reuse the existing Shop crumb instead of inserting another one.'
+ );
+ }
+
+ /**
+ * @testdox Should not prepend the shop page when product permalinks do not include the shop slug.
+ */
+ public function test_core_breadcrumbs_do_not_prepend_shop_page_for_product_base_without_shop_slug(): void {
+ $woocommerce_permalinks = is_array( $this->original_woocommerce_permalinks ) ? $this->original_woocommerce_permalinks : array();
+ $woocommerce_permalinks['product_base'] = '/product';
+ update_option( 'woocommerce_permalinks', $woocommerce_permalinks );
+
+ $category_id = $this->create_term( 'Shirts', 'product_cat', array( 'slug' => 'shirts' ) );
+ $this->go_to( get_term_link( $category_id, 'product_cat' ) );
+
+ $this->assert_core_breadcrumbs_unchanged(
+ 'Product category breadcrumbs should remain unchanged when WooCommerce would not prepend Shop.',
+ $this->get_breadcrumb_item( 'Shirts' )
+ );
+ }
+
+ /**
+ * @testdox Should preserve pagination when labeling product tag breadcrumbs.
+ */
+ public function test_core_breadcrumbs_label_paginated_product_tag_items(): void {
+ $tag_id = $this->create_term( 'Sale', 'product_tag', array( 'slug' => 'sale' ) );
+ $this->go_to( get_term_link( $tag_id, 'product_tag' ) );
+ set_query_var( 'paged', 2 );
+
+ $this->assert_core_breadcrumb_labels(
+ array( 'Home', 'Catalog', 'Products tagged “Sale”', 'Page 2' ),
+ 'Paginated product tag breadcrumbs should keep the pagination crumb.',
+ $this->get_breadcrumb_item( 'Sale', get_term_link( $tag_id, 'product_tag' ) ),
+ $this->get_breadcrumb_item( 'Page 2' )
+ );
+ }
+
+ /**
+ * @testdox Should preserve pagination when labeling product search breadcrumbs.
+ */
+ public function test_core_breadcrumbs_label_paginated_product_search_items(): void {
+ global $wp_query;
+
+ $this->go_to( '/?s=hoodie&post_type=product' );
+
+ $wp_query->is_search = true;
+ $wp_query->is_post_type_archive = true;
+ $wp_query->is_archive = true;
+ $wp_query->is_404 = false;
+
+ set_query_var( 'paged', 2 );
+
+ $this->assert_core_breadcrumb_labels(
+ array( 'Home', 'Catalog', 'Search results for “hoodie”', 'Page 2' ),
+ 'Paginated product search breadcrumbs should keep the pagination crumb.',
+ $this->get_breadcrumb_item( 'Search results for: "hoodie"', get_pagenum_link( 1 ) ),
+ $this->get_breadcrumb_item( 'Page 2' )
+ );
+ }
+
+ /**
+ * @testdox Should preserve the Shop breadcrumb when the current product search item is hidden.
+ */
+ public function test_core_breadcrumbs_preserve_shop_item_when_product_search_current_item_is_hidden(): void {
+ global $wp_query;
+
+ $this->go_to( '/?s=hoodie&post_type=product' );
+
+ $wp_query->is_search = true;
+ $wp_query->is_post_type_archive = true;
+ $wp_query->is_archive = true;
+ $wp_query->is_404 = false;
+
+ $result = $this->apply_core_breadcrumb_filters();
+
+ $this->assertSame(
+ array( 'Home', 'Catalog' ),
+ $this->get_breadcrumb_labels( $result ),
+ 'The Shop crumb should not be relabeled as the current search when Core omits that item.'
+ );
+ }
+
+ /**
+ * @testdox Should preserve Core's hidden Home setting on product searches.
+ */
+ public function test_core_breadcrumbs_preserve_hidden_home_item_on_product_search(): void {
+ global $wp_query;
+
+ $this->go_to( '/?s=hoodie&post_type=product' );
+
+ $wp_query->is_search = true;
+ $wp_query->is_post_type_archive = true;
+ $wp_query->is_archive = true;
+ $wp_query->is_404 = false;
+
+ $result = $this->apply_breadcrumb_filters(
+ array( $this->get_breadcrumb_item( 'Search results for: "hoodie"' ) )
+ );
+
+ $this->assertSame(
+ array( 'Catalog', 'Search results for “hoodie”' ),
+ $this->get_breadcrumb_labels( $result ),
+ 'The Home crumb should remain hidden when WooCommerce compatibility adds the Shop crumb.'
+ );
+ }
+
+ /**
+ * @testdox Should preserve Core's hidden Home and current item settings on product searches.
+ */
+ public function test_core_breadcrumbs_preserve_hidden_home_and_current_items_on_product_search(): void {
+ global $wp_query;
+
+ $this->go_to( '/?s=hoodie&post_type=product' );
+
+ $wp_query->is_search = true;
+ $wp_query->is_post_type_archive = true;
+ $wp_query->is_archive = true;
+ $wp_query->is_404 = false;
+
+ $result = $this->apply_breadcrumb_filters( array() );
+
+ $this->assertSame(
+ array( 'Catalog' ),
+ $this->get_breadcrumb_labels( $result ),
+ 'Home and current crumbs should remain hidden while WooCommerce compatibility keeps the Shop parent.'
+ );
+ }
+
+ /**
+ * @testdox Should use WooCommerce labels for regular search breadcrumbs.
+ */
+ public function test_core_breadcrumbs_label_regular_search_items(): void {
+ $this->go_to( '/?s=breadcrumb' );
+
+ $this->assert_core_breadcrumb_labels(
+ array( 'Home', 'Search results for “breadcrumb”' ),
+ 'Regular search breadcrumbs should use the WooCommerce search label.',
+ $this->get_breadcrumb_item( 'Search results for: "breadcrumb"' )
+ );
+ }
+
+ /**
+ * @testdox Should prepend taxonomy labels for product attribute breadcrumbs.
+ */
+ public function test_core_breadcrumbs_prepend_taxonomy_label_to_product_attribute_items(): void {
+ register_taxonomy(
+ 'pa_test_color',
+ array( 'product' ),
+ array(
+ 'labels' => array(
+ 'name' => 'Colors',
+ 'singular_name' => 'Color',
+ ),
+ 'public' => true,
+ 'show_in_rest' => true,
+ 'hierarchical' => true,
+ )
+ );
+
+ $term_id = $this->create_term( 'Blue', 'pa_test_color', array( 'slug' => 'blue' ) );
+ $this->go_to( get_term_link( $term_id, 'pa_test_color' ) );
+ $this->set_product_taxonomy_request( $term_id, 'pa_test_color' );
+
+ $this->assert_core_breadcrumb_labels(
+ array( 'Home', 'Colors', 'Blue' ),
+ 'Product attribute breadcrumbs should include the attribute taxonomy label crumb.',
+ $this->get_breadcrumb_item( 'Blue' )
+ );
+ }
+
+ /**
+ * @testdox Should prepend the My Account page to endpoint breadcrumbs.
+ */
+ public function test_core_breadcrumbs_prepend_my_account_page_to_endpoint_items(): void {
+ $this->set_account_endpoint_request( 'orders' );
+
+ $this->assert_core_breadcrumb_labels(
+ array( 'Home', 'My account', 'Orders' ),
+ 'My Account endpoint breadcrumbs should include the account page crumb.',
+ $this->get_breadcrumb_item( 'Orders' )
+ );
+ }
+
+ /**
+ * @testdox Should replace the account page title with the current endpoint title.
+ */
+ public function test_core_breadcrumbs_replace_account_page_title_with_endpoint_title(): void {
+ $this->set_account_endpoint_request( 'orders' );
+
+ $this->assert_core_breadcrumb_labels(
+ array( 'Home', 'My account', 'Orders' ),
+ 'My Account endpoint breadcrumbs should use the endpoint title for the current item.',
+ $this->get_breadcrumb_item( 'My account' )
+ );
+ }
+
+ /**
+ * @testdox Should not duplicate the My Account page on endpoint breadcrumbs.
+ */
+ public function test_core_breadcrumbs_do_not_duplicate_existing_my_account_page_item(): void {
+ $this->set_account_endpoint_request( 'orders' );
+
+ $this->assert_core_breadcrumbs_unchanged(
+ 'My Account endpoint breadcrumbs should reuse the existing My Account crumb.',
+ $this->get_breadcrumb_item( 'My account', get_permalink( $this->my_account_page_id ) ),
+ $this->get_breadcrumb_item( 'Orders' )
+ );
+ }
+
+ /**
+ * @testdox Should run the legacy WooCommerce breadcrumb filter after adjustments.
+ */
+ public function test_core_breadcrumbs_apply_legacy_woocommerce_get_breadcrumb_filter_after_adjustments(): void {
+ $callback = function ( $crumbs ) {
+ $crumbs[1][0] = 'Filtered Catalog';
+ return $crumbs;
+ };
+ add_filter( 'woocommerce_get_breadcrumb', $callback );
+ $this->go_to( $this->get_product_archive_url() );
+ $this->set_product_archive_request();
+
+ try {
+ $result = $this->apply_core_breadcrumb_filters(
+ $this->get_breadcrumb_item( 'All Products', $this->get_product_archive_url() ),
+ $this->get_breadcrumb_item( 'Logo Tee' )
+ );
+ } finally {
+ remove_filter( 'woocommerce_get_breadcrumb', $callback );
+ }
+
+ $this->assertSame(
+ array( 'Home', 'Filtered Catalog', 'Logo Tee' ),
+ $this->get_breadcrumb_labels( $result ),
+ 'Legacy breadcrumb filters should receive WooCommerce-adjusted Core breadcrumb items.'
+ );
+ }
+
+ /**
+ * @testdox Should apply legacy WooCommerce breadcrumb filters outside WooCommerce contexts.
+ */
+ public function test_core_breadcrumbs_apply_legacy_woocommerce_get_breadcrumb_filter_outside_woocommerce_contexts(): void {
+ $callback = function ( $crumbs ) {
+ $crumbs[1][0] = 'Filtered page';
+ return $crumbs;
+ };
+ add_filter( 'woocommerce_get_breadcrumb', $callback );
+ $this->go_to( '/?s=breadcrumb' );
+
+ try {
+ $result = $this->apply_core_breadcrumb_filters(
+ $this->get_breadcrumb_item( 'Search results for: "breadcrumb"' )
+ );
+ } finally {
+ remove_filter( 'woocommerce_get_breadcrumb', $callback );
+ }
+
+ $this->assertSame(
+ array( 'Home', 'Filtered page' ),
+ $this->get_breadcrumb_labels( $result ),
+ 'Legacy WooCommerce breadcrumb filters should run for regular Core breadcrumbs.'
+ );
+ }
+
+ /**
+ * @testdox Should preserve Core breadcrumb item metadata when legacy filters run.
+ */
+ public function test_core_breadcrumbs_preserve_core_item_metadata_when_legacy_filters_run(): void {
+ $callback = function ( $crumbs ) {
+ return $crumbs;
+ };
+ add_filter( 'woocommerce_get_breadcrumb', $callback );
+ $this->go_to( $this->get_product_archive_url() );
+ $this->set_product_archive_request();
+
+ $product_item = $this->get_breadcrumb_item(
+ '<span>Logo Tee</span>',
+ null,
+ array(
+ 'allow_html' => true,
+ )
+ );
+
+ try {
+ $result = $this->apply_core_breadcrumb_filters( $product_item );
+ } finally {
+ remove_filter( 'woocommerce_get_breadcrumb', $callback );
+ }
+
+ $this->assertSame(
+ $this->get_core_breadcrumb_items( $product_item ),
+ $result,
+ 'Core breadcrumb item metadata should survive WooCommerce breadcrumb filter conversion.'
+ );
+ }
+
+ /**
+ * Get the Home breadcrumb item.
+ *
+ * @param string|null $url Home URL.
+ * @return array Home breadcrumb item.
+ */
+ private function get_home_breadcrumb_item( ?string $url = null ): array {
+ return $this->get_breadcrumb_item( 'Home', $url ?? home_url( '/' ) );
+ }
+
+ /**
+ * Get a breadcrumb item.
+ *
+ * @param string $label Breadcrumb label.
+ * @param string|null $url Breadcrumb URL.
+ * @param array $metadata Extra breadcrumb metadata.
+ * @return array Breadcrumb item.
+ */
+ private function get_breadcrumb_item( string $label, ?string $url = null, array $metadata = array() ): array {
+ $item = array_merge(
+ array(
+ 'label' => $label,
+ ),
+ $metadata
+ );
+
+ if ( null !== $url ) {
+ $item['url'] = $url;
+ }
+
+ return $item;
+ }
+
+ /**
+ * Get Core breadcrumb items with a Home item.
+ *
+ * @param array ...$items Breadcrumb items after Home.
+ * @return array Breadcrumb items.
+ */
+ private function get_core_breadcrumb_items( array ...$items ): array {
+ array_unshift( $items, $this->get_home_breadcrumb_item() );
+
+ return $items;
+ }
+
+ /**
+ * Apply WooCommerce breadcrumb compatibility filters to Core breadcrumb items.
+ *
+ * @param array ...$items Breadcrumb items after Home.
+ * @return array Filtered breadcrumb items.
+ */
+ private function apply_core_breadcrumb_filters( array ...$items ): array {
+ return $this->apply_breadcrumb_filters( $this->get_core_breadcrumb_items( ...$items ) );
+ }
+
+ /**
+ * Apply WooCommerce breadcrumb compatibility filters.
+ *
+ * @param array $items Breadcrumb items.
+ * @return array Filtered breadcrumb items.
+ */
+ private function apply_breadcrumb_filters( array $items ): array {
+ return $this->sut->apply_woocommerce_breadcrumb_filters( $items );
+ }
+
+ /**
+ * Assert breadcrumb labels after applying filters to Core breadcrumb items.
+ *
+ * @param array $expected_labels Expected breadcrumb labels.
+ * @param string $message Assertion message.
+ * @param array ...$items Breadcrumb items after Home.
+ */
+ private function assert_core_breadcrumb_labels( array $expected_labels, string $message, array ...$items ): void {
+ $this->assertSame(
+ $expected_labels,
+ $this->get_breadcrumb_labels( $this->apply_core_breadcrumb_filters( ...$items ) ),
+ $message
+ );
+ }
+
+ /**
+ * Assert Core breadcrumb items are unchanged after applying filters.
+ *
+ * @param string $message Assertion message.
+ * @param array ...$items Breadcrumb items after Home.
+ */
+ private function assert_core_breadcrumbs_unchanged( string $message, array ...$items ): void {
+ $core_items = $this->get_core_breadcrumb_items( ...$items );
+
+ $this->assertSame(
+ $core_items,
+ $this->apply_breadcrumb_filters( $core_items ),
+ $message
+ );
+ }
+
+ /**
+ * Get labels from breadcrumb items.
+ *
+ * @param array $items Breadcrumb items.
+ * @return array Breadcrumb labels.
+ */
+ private function get_breadcrumb_labels( array $items ): array {
+ return array_column( $items, 'label' );
+ }
+
+ /**
+ * Get the product archive URL.
+ *
+ * @return string Product archive URL.
+ */
+ private function get_product_archive_url(): string {
+ $url = get_post_type_archive_link( 'product' );
+
+ if ( ! is_string( $url ) ) {
+ $this->fail( 'Expected the product archive link to be available.' );
+ }
+
+ return $url;
+ }
+
+ /**
+ * Get the Shop page URL.
+ *
+ * @return string Shop page URL.
+ */
+ private function get_shop_page_url(): string {
+ $url = get_permalink( $this->shop_page_id );
+
+ if ( ! is_string( $url ) ) {
+ $this->fail( 'Expected the Shop page permalink to be available.' );
+ }
+
+ return $url;
+ }
+
+ /**
+ * Set the current request as a My Account endpoint.
+ *
+ * @param string $endpoint Endpoint name.
+ */
+ private function set_account_endpoint_request( string $endpoint ): void {
+ global $wp, $wp_query;
+
+ WC()->query = new \WC_Query();
+
+ if ( ! $wp instanceof \WP ) {
+ $wp = new \WP(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ }
+
+ if ( ! $wp_query instanceof \WP_Query ) {
+ $wp_query = new \WP_Query(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ }
+
+ if ( ! is_array( $wp->query_vars ) ) {
+ $wp->query_vars = array();
+ }
+
+ $wp->query_vars[ $endpoint ] = '';
+
+ add_filter( 'woocommerce_is_account_page', '__return_true' );
+ }
+
+ /**
+ * Set the current request as the product archive.
+ */
+ private function set_product_archive_request(): void {
+ global $wp_query;
+
+ $wp_query->is_post_type_archive = true;
+ $wp_query->is_archive = true;
+ $wp_query->is_404 = false;
+
+ set_query_var( 'post_type', 'product' );
+ }
+
+ /**
+ * Set the current request as a product taxonomy archive.
+ *
+ * @param int $term_id Term ID.
+ * @param string $taxonomy Taxonomy name.
+ */
+ private function set_product_taxonomy_request( int $term_id, string $taxonomy ): void {
+ global $wp_query;
+
+ $term = get_term( $term_id, $taxonomy );
+
+ if ( ! $term instanceof \WP_Term ) {
+ $this->fail( 'Expected product taxonomy term to be available.' );
+ }
+
+ $wp_query->queried_object = $term;
+ $wp_query->queried_object_id = $term_id;
+ $wp_query->is_tax = true;
+ $wp_query->is_archive = true;
+ $wp_query->is_404 = false;
+
+ set_query_var( 'taxonomy', $taxonomy );
+ set_query_var( 'term', $term->slug );
+ }
+
+ /**
+ * Create a taxonomy term.
+ *
+ * @param string $name Term name.
+ * @param string $taxonomy Taxonomy name.
+ * @param array $args Term arguments.
+ * @return int Term ID.
+ */
+ private function create_term( string $name, string $taxonomy, array $args = array() ): int {
+ $term = wp_insert_term( $name, $taxonomy, $args );
+
+ if ( is_wp_error( $term ) ) {
+ $this->fail( $term->get_error_message() );
+ }
+
+ return (int) $term['term_id'];
+ }
+}