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.' );
}
/**