Commit 3b480643d3a for woocommerce

commit 3b480643d3a383524fa6567edeba494cf9494aae
Author: Bero <1199991+bgrgicak@users.noreply.github.com>
Date:   Fri Oct 30 10:04:07 2026 +0100

    Add prices and availability to shared product links (#70281)

    * Separate Open Graph page metadata from HTML output

    * Add changelog entry for Open Graph refactor

    * Add prices and availability to shared product links

    * Add changelog entry for Open Graph prices

    * Refactor Open Graph page routing and product validation

    * Add changelog entry for product previews through share links

    * Simplify Open Graph price conflict handling

    * Document Open Graph description and locale patterns

    * Simplify Open Graph price and availability fallbacks

    * Refactor Open Graph tag suppression into tag groups

    * Move shared Open Graph tag groups into the foundation

    * Use ordered tag groups for Open Graph metadata

diff --git a/plugins/woocommerce/changelog/add-open-graph-prices b/plugins/woocommerce/changelog/add-open-graph-prices
new file mode 100644
index 00000000000..4123c8e5652
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-open-graph-prices
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Include prices, sale dates, and stock availability in shared product links.
diff --git a/plugins/woocommerce/src/Internal/StructuredData/OpenGraphMarkup.php b/plugins/woocommerce/src/Internal/StructuredData/OpenGraphMarkup.php
index 78c8bbe49a0..69cfabe2774 100644
--- a/plugins/woocommerce/src/Internal/StructuredData/OpenGraphMarkup.php
+++ b/plugins/woocommerce/src/Internal/StructuredData/OpenGraphMarkup.php
@@ -39,15 +39,35 @@ class OpenGraphMarkup {
 		/**
 		 * Metadata ownership rules.
 		 *
-		 * @var array<string, array{detect?: string, match: string, exists: bool}> $tag_groups
+		 * @var array<string, array{detect?: string, match: string|string[], exists: bool}> $tag_groups
 		 */
 		$tag_groups = array(
-			'image' => array(
+			'image'        => array(
 				'detect' => '/^og:image(?::url)?$/D',
 				'match'  => '/^og:image(?::|$)/D',
 				'exists' => false,
 			),
-			'video' => array(
+			'prices'       => array(
+				'match'  => array(
+					'product:price:amount',
+					'product:price:currency',
+					'product:sale_price:amount',
+					'product:sale_price:currency',
+					'product:sale_price_dates:start',
+					'product:sale_price_dates:end',
+					'og:price:amount',
+					'og:price:currency',
+					'og:price:standard_amount',
+					'og:price:start_date',
+					'og:price:end_date',
+				),
+				'exists' => false,
+			),
+			'availability' => array(
+				'match'  => '/^(product|og):availability$/D',
+				'exists' => false,
+			),
+			'video'        => array(
 				'detect' => '/^og:video(?::url)?$/D',
 				'match'  => '/^og:video(?::|$)/D',
 				'exists' => false,
@@ -89,7 +109,7 @@ class OpenGraphMarkup {
 					continue;
 				}
 				foreach ( $tag_groups as $group_name => $group ) {
-					if ( preg_match( $group['detect'] ?? $group['match'], $property ) ) {
+					if ( self::matches_property( $group['detect'] ?? $group['match'], $property ) ) {
 						$tag_groups[ $group_name ]['exists'] = true;
 					}
 				}
@@ -105,7 +125,7 @@ class OpenGraphMarkup {
 			}
 			foreach ( $candidate as $property => $value ) {
 				foreach ( $tag_groups as $group ) {
-					if ( $group['exists'] && preg_match( $group['match'], $property ) ) {
+					if ( $group['exists'] && self::matches_property( $group['match'], $property ) ) {
 						unset( $tags[ $index ] );
 						continue 3;
 					}
@@ -127,6 +147,17 @@ class OpenGraphMarkup {
 		return self::insert_tags( $processor->get_updated_html(), $tags );
 	}

+	/**
+	 * Check whether a property belongs to a metadata group.
+	 *
+	 * @param string|string[] $matcher  Pattern or property names.
+	 * @param string          $property Metadata property.
+	 * @return bool
+	 */
+	private static function matches_property( $matcher, string $property ): bool {
+		return is_array( $matcher ) ? in_array( $property, $matcher, true ) : 1 === preg_match( $matcher, $property );
+	}
+
 	/**
 	 * Escape metadata and insert it at the end of the head.
 	 *
diff --git a/plugins/woocommerce/src/Internal/StructuredData/ProductOpenGraphTags.php b/plugins/woocommerce/src/Internal/StructuredData/ProductOpenGraphTags.php
index de04cf4cc72..404a2e97993 100644
--- a/plugins/woocommerce/src/Internal/StructuredData/ProductOpenGraphTags.php
+++ b/plugins/woocommerce/src/Internal/StructuredData/ProductOpenGraphTags.php
@@ -9,6 +9,8 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Internal\StructuredData;

+use Automattic\WooCommerce\Enums\ProductStockStatus;
+use Automattic\WooCommerce\Enums\ProductType;
 use WC_Product;

 defined( 'ABSPATH' ) || exit;
@@ -60,6 +62,28 @@ class ProductOpenGraphTags {
 		}
 		$tags[] = array( 'twitter:card' => $urls ? 'summary_large_image' : 'summary' );

+		$availability = array(
+			ProductStockStatus::IN_STOCK     => array(
+				'product:availability' => 'in stock',
+				'og:availability'      => 'instock',
+			),
+			ProductStockStatus::OUT_OF_STOCK => array(
+				'product:availability' => 'out of stock',
+				'og:availability'      => 'out of stock',
+			),
+			ProductStockStatus::ON_BACKORDER => array(
+				'product:availability' => 'available for order',
+				'og:availability'      => 'backorder',
+			),
+		);
+		foreach ( $availability[ $product->get_stock_status() ] ?? array() as $property => $value ) {
+			$tags[] = array( $property => $value );
+		}
+
+		foreach ( $this->get_price_tags( $product ) as $property => $value ) {
+			$tags[] = array( $property => $value );
+		}
+
 		foreach ( wc_get_container()->get( ProductMediaSources::class )->get_sources( $product ) as $media => $source ) {
 			$root  = 'og:' . $media;
 			$group = array();
@@ -114,4 +138,41 @@ class ProductOpenGraphTags {

 		return $tags;
 	}
+
+	/**
+	 * Build prices and sale dates for products with a single price.
+	 *
+	 * @param WC_Product $product Product being shared.
+	 * @return array<string, string>
+	 */
+	private function get_price_tags( WC_Product $product ): array {
+		if ( ! $product->is_type( array( ProductType::SIMPLE, ProductType::EXTERNAL ) ) || '' === $product->get_price() ) {
+			return array();
+		}
+
+		$price    = wc_format_decimal( wc_get_price_to_display( $product ), wc_get_price_decimals() );
+		$currency = get_woocommerce_currency();
+		$tags     = array(
+			'product:price:amount'   => $price,
+			'product:price:currency' => $currency,
+			'og:price:amount'        => $price,
+			'og:price:currency'      => $currency,
+		);
+		if ( $product->is_on_sale() ) {
+			$tags['product:sale_price:amount']   = $price;
+			$tags['product:sale_price:currency'] = $currency;
+			$tags['og:price:standard_amount']    = wc_format_decimal( wc_get_price_to_display( $product, array( 'price' => $product->get_regular_price() ) ), wc_get_price_decimals() );
+			foreach ( array(
+				'start' => $product->get_date_on_sale_from(),
+				'end'   => $product->get_date_on_sale_to(),
+			) as $boundary => $date ) {
+				if ( $date ) {
+					$tags[ 'product:sale_price_dates:' . $boundary ] = $date->format( DATE_ATOM );
+					$tags[ 'og:price:' . $boundary . '_date' ]       = $date->format( DATE_ATOM );
+				}
+			}
+		}
+
+		return $tags;
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/StructuredData/OpenGraphTest.php b/plugins/woocommerce/tests/php/src/Internal/StructuredData/OpenGraphTest.php
index 7c608465768..d3e4e7cdb15 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StructuredData/OpenGraphTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StructuredData/OpenGraphTest.php
@@ -46,6 +46,8 @@ class OpenGraphTest extends WC_Unit_Test_Case {

 		update_option( 'woocommerce_coming_soon', 'no' );
 		update_option( 'blogname', 'Test store' );
+		update_option( 'woocommerce_currency', 'USD' );
+		update_option( 'woocommerce_price_num_decimals', 2 );

 		$image_id = self::factory()->attachment->create(
 			array(
@@ -108,6 +110,67 @@ class OpenGraphTest extends WC_Unit_Test_Case {
 		$this->assertSame( array( 'Product photo' ), $tags['og:image:alt'], 'Image alternative text should be added.' );
 		$this->assertSame( array( 'image/jpeg' ), $tags['og:image:type'], 'Image MIME type should be added.' );
 		$this->assertSame( array( 'summary_large_image' ), $tags['twitter:card'], 'Products with an image should use a large image card.' );
+		$this->assertSame( array( '10.00' ), $tags['product:price:amount'], 'The product price should be added.' );
+		$this->assertSame( array( 'USD' ), $tags['product:price:currency'], 'The store currency should be added.' );
+	}
+
+	/**
+	 * @testdox Should render sale prices and sale dates in product metadata.
+	 */
+	public function test_sale_price_and_dates(): void {
+		$start = time() - DAY_IN_SECONDS;
+		$end   = time() + DAY_IN_SECONDS;
+		$this->product->set_regular_price( '10' );
+		$this->product->set_sale_price( '8' );
+		$this->product->set_date_on_sale_from( $start );
+		$this->product->set_date_on_sale_to( $end );
+		$this->product->save();
+
+		$tags = $this->render_meta_tags( '' );
+		$this->assertSame( array( '8.00' ), $tags['product:sale_price:amount'] );
+		$this->assertSame( array( 'USD' ), $tags['product:sale_price:currency'] );
+		$this->assertSame( array( '8.00' ), $tags['og:price:amount'] );
+		$this->assertSame( array( '10.00' ), $tags['og:price:standard_amount'] );
+		$this->assertSame( array( $this->product->get_date_on_sale_from()->format( DATE_ATOM ) ), $tags['product:sale_price_dates:start'] );
+		$this->assertSame( array( $this->product->get_date_on_sale_to()->format( DATE_ATOM ) ), $tags['product:sale_price_dates:end'] );
+		$this->assertSame( $tags['product:sale_price_dates:start'], $tags['og:price:start_date'] );
+		$this->assertSame( $tags['product:sale_price_dates:end'], $tags['og:price:end_date'] );
+	}
+
+	/**
+	 * @testdox Should render the corresponding availability for each stock status.
+	 * @testWith ["instock", "in stock", "instock"]
+	 *           ["outofstock", "out of stock", "out of stock"]
+	 *           ["onbackorder", "available for order", "backorder"]
+	 *
+	 * @param string $status Product stock status.
+	 * @param string $product_availability Product availability value.
+	 * @param string $og_availability Open Graph availability value.
+	 */
+	public function test_stock_availability( string $status, string $product_availability, string $og_availability ): void {
+		$this->product->set_stock_status( $status );
+		$this->product->save();
+		$tags = $this->render_meta_tags( '' );
+
+		$this->assertSame( array( $product_availability ), $tags['product:availability'] );
+		$this->assertSame( array( $og_availability ), $tags['og:availability'] );
+	}
+
+	/**
+	 * @testdox Should omit prices for variable and grouped products.
+	 * @testWith ["create_variation_product"]
+	 *           ["create_grouped_product"]
+	 *
+	 * @param string $factory Product fixture method.
+	 */
+	public function test_omits_price_for_products_without_a_single_price( string $factory ): void {
+		$product = WC_Helper_Product::$factory();
+		$this->go_to( $product->get_permalink() );
+		$tags = $this->render_meta_tags( '' );
+
+		$this->assertSame( array( $product->get_name() ), $tags['og:title'] );
+		$this->assertArrayNotHasKey( 'product:price:amount', $tags );
+		$this->assertArrayNotHasKey( 'og:price:amount', $tags );
 	}

 	/**
@@ -128,7 +191,9 @@ class OpenGraphTest extends WC_Unit_Test_Case {
 			<meta property="og:description" content="SEO description">
 			<meta property="og:image" content="https://example.org/seo.jpg">
 			<meta property="og:image:width" content="600">
-			<meta name="twitter:card" content="summary">'
+			<meta name="twitter:card" content="summary">
+			<meta property="product:price:amount" content="25.00">
+			<meta property="product:price:currency" content="EUR">'
 		);

 		$this->assertSame( array( 'SEO title' ), $tags['og:title'], 'An existing title should be preserved without duplication.' );
@@ -140,6 +205,8 @@ class OpenGraphTest extends WC_Unit_Test_Case {
 		$this->assertArrayNotHasKey( 'og:image:type', $tags, 'The featured image MIME type should not describe another provider\'s image.' );
 		$this->assertSame( array( 'summary' ), $tags['twitter:card'], 'An existing Twitter card should be preserved.' );
 		$this->assertSame( array( 'product' ), $tags['og:type'], 'Missing metadata should still be added alongside existing tags.' );
+		$this->assertSame( array( '25.00' ), $tags['product:price:amount'], 'An existing price should be preserved without duplication.' );
+		$this->assertSame( array( 'EUR' ), $tags['product:price:currency'], 'An existing currency should be preserved without duplication.' );
 	}

 	/**