Commit d3470ed8b89 for woocommerce
commit d3470ed8b8918adaf2b5d3693f8067ce0e1a9702
Author: Bero <1199991+bgrgicak@users.noreply.github.com>
Date: Wed Nov 4 07:39:43 2026 +0100
Add category paths to Product schema (#70585)
* Add category paths to product structured data
* Add changelog entry for product category paths
* Fix encoding in product category structured data
* Fix category lookup in product structured data
diff --git a/plugins/woocommerce/changelog/add-product-schema-category-paths b/plugins/woocommerce/changelog/add-product-schema-category-paths
new file mode 100644
index 00000000000..c890278eb0d
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-product-schema-category-paths
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Include full product category paths in structured data.
diff --git a/plugins/woocommerce/includes/class-wc-structured-data.php b/plugins/woocommerce/includes/class-wc-structured-data.php
index f9c04b9aeab..4cc02958fe2 100644
--- a/plugins/woocommerce/includes/class-wc-structured-data.php
+++ b/plugins/woocommerce/includes/class-wc-structured-data.php
@@ -193,7 +193,7 @@ class WC_Structured_Data {
$data = $this->get_structured_data( $types );
if ( $data ) {
- echo '<script type="application/ld+json">' . wc_esc_json( wp_json_encode( $data, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), true ) . '</script>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ echo '<script type="application/ld+json">' . wc_esc_json( wp_json_encode( $data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_SLASHES ), true ) . '</script>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
}
@@ -598,6 +598,31 @@ class WC_Structured_Data {
);
}
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Reuse the breadcrumb filter.
+ $term_args = apply_filters(
+ 'woocommerce_breadcrumb_product_terms_args',
+ array(
+ 'orderby' => 'parent',
+ 'order' => 'DESC',
+ )
+ );
+ $product_id = $product instanceof WC_Product_Variation ? $product->get_parent_id() : $product->get_id();
+ $terms = is_array( $term_args ) ? wc_get_product_terms( $product_id, 'product_cat', $term_args ) : array();
+ if ( ! empty( $terms ) && is_array( $terms ) ) {
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Reuse the breadcrumb filter.
+ $term = apply_filters( 'woocommerce_breadcrumb_main_term', reset( $terms ), $terms );
+ $names = array();
+ $seen = array();
+ while ( $term instanceof WP_Term && 'product_cat' === $term->taxonomy && ! isset( $seen[ $term->term_id ] ) ) {
+ $seen[ $term->term_id ] = true;
+ $names[] = html_entity_decode( $term->name, ENT_QUOTES, get_bloginfo( 'charset' ) );
+ if ( ! $term->parent ) {
+ $markup['category'] = implode( ' > ', array_reverse( $names ) );
+ break;
+ }
+ $term = get_term( $term->parent, 'product_cat' );
+ }
+ }
$this->set_data( apply_filters( 'woocommerce_structured_data_product', $markup, $product ) );
}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php b/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
index 67a46b26bb2..3a9160e68d0 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
@@ -375,6 +375,66 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
$this->assertSame( 'EXTENSION-MPN', $sut->get_data()[0]['mpn'] );
}
+ /**
+ * @testdox Product schema follows the breadcrumb category selection and includes its ancestors.
+ */
+ public function test_product_category_path(): void {
+ $parent = wp_insert_term( 'Clothing', 'product_cat' );
+ $child = wp_insert_term( 'Hoodies', 'product_cat', array( 'parent' => $parent['term_id'] ) );
+ $other = wp_insert_term( 'Accessories', 'product_cat' );
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_category_ids( array( $child['term_id'], $other['term_id'] ) );
+ $product->save();
+
+ $sut = new WC_Structured_Data();
+ $sut->generate_product_data( $product );
+ $this->assertSame( 'Clothing > Hoodies', $sut->get_data()[0]['category'] );
+
+ add_filter(
+ 'woocommerce_breadcrumb_main_term',
+ static function () use ( $other ) {
+ return get_term( $other['term_id'], 'product_cat' );
+ }
+ );
+ $sut = new WC_Structured_Data();
+ $sut->generate_product_data( $product );
+ $this->assertSame( 'Accessories', $sut->get_data()[0]['category'] );
+ }
+
+ /**
+ * @testdox Product schema output preserves ampersands in category names.
+ */
+ public function test_product_category_output_preserves_ampersands(): void {
+ $category = wp_insert_term( 'Food & Drink', 'product_cat' );
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_category_ids( array( $category['term_id'] ) );
+ $product->save();
+ $this->go_to( get_permalink( $product->get_id() ) );
+
+ $sut = new WC_Structured_Data();
+ $sut->generate_product_data( $product );
+ ob_start();
+ $sut->output_structured_data();
+ $output = ob_get_clean();
+
+ $this->assertMatchesRegularExpression( '~^<script type="application/ld\+json">(.*)</script>$~s', $output );
+ preg_match( '~^<script type="application/ld\+json">(.*)</script>$~s', $output, $matches );
+ $data = json_decode( $matches[1], true );
+ $this->assertSame( 'Food & Drink', $data['category'] );
+ }
+
+ /**
+ * @testdox Product schema omits an invalid breadcrumb category.
+ */
+ public function test_product_with_invalid_category_path(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ add_filter( 'woocommerce_breadcrumb_main_term', '__return_null' );
+
+ $sut = new WC_Structured_Data();
+ $sut->generate_product_data( $product );
+ $this->assertArrayNotHasKey( 'category', $sut->get_data()[0] );
+ }
+
/**
* @testdox Product schema uses the selected variation's MPN without inheriting the parent's value.
* @testWith ["CHILD-PART", true, "CHILD-PART"]