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"]