Commit 9e9f82b128d for woocommerce

commit 9e9f82b128d8900fbc0d39b1656fcfcdf240e56e
Author: Bero <1199991+bgrgicak@users.noreply.github.com>
Date:   Tue Nov 3 08:29:16 2026 +0100

    Add Open Graph metadata to the shop page (#70324)

    * Add Open Graph metadata to the shop page

    * Fix Open Graph class closing brace

    * Fix Open Graph metadata on coming soon pages

    * Generate shop descriptions from page content

    * Test shop metadata with share links

    * Fix markup in shop Open Graph titles

    * Correct Open Graph docblocks

    * Cover excluded shop views and remove early check

    * Exclude paginated shop views from Open Graph metadata

diff --git a/plugins/woocommerce/changelog/add-shop-open-graph b/plugins/woocommerce/changelog/add-shop-open-graph
new file mode 100644
index 00000000000..30fa5714949
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-shop-open-graph
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Improve shared shop links with Open Graph metadata.
diff --git a/plugins/woocommerce/src/Internal/ComingSoon/ComingSoonRequestHandler.php b/plugins/woocommerce/src/Internal/ComingSoon/ComingSoonRequestHandler.php
index 2769a3c3bd9..a9c9ec9de95 100644
--- a/plugins/woocommerce/src/Internal/ComingSoon/ComingSoonRequestHandler.php
+++ b/plugins/woocommerce/src/Internal/ComingSoon/ComingSoonRequestHandler.php
@@ -28,6 +28,18 @@ class ComingSoonRequestHandler {
 	 */
 	private static $show_coming_soon = null;

+	/**
+	 * Whether the coming soon screen was selected for this request.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @internal
+	 * @return bool
+	 */
+	public static function is_showing_coming_soon(): bool {
+		return true === self::$show_coming_soon;
+	}
+
 	/**
 	 * Sets up the hook.
 	 *
diff --git a/plugins/woocommerce/src/Internal/StructuredData/OpenGraph.php b/plugins/woocommerce/src/Internal/StructuredData/OpenGraph.php
index 343dcb4fb4c..f83b89ff385 100644
--- a/plugins/woocommerce/src/Internal/StructuredData/OpenGraph.php
+++ b/plugins/woocommerce/src/Internal/StructuredData/OpenGraph.php
@@ -1,6 +1,6 @@
 <?php
 /**
- * Product Open Graph metadata.
+ * Open Graph metadata.
  *
  * @package WooCommerce
  */
@@ -10,12 +10,13 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Internal\StructuredData;

 use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonRequestHandler;
 use WC_Product;

 defined( 'ABSPATH' ) || exit;

 /**
- * Provides product link previews and fills missing Open Graph metadata.
+ * Provides link previews and fills missing Open Graph metadata.
  */
 class OpenGraph {

@@ -43,7 +44,7 @@ class OpenGraph {
 	}

 	/**
-	 * Prepare metadata for a public, unprotected product before rendering the template.
+	 * Prepare metadata for a public, unprotected page before rendering the template.
 	 *
 	 * @internal
 	 */
@@ -81,7 +82,7 @@ class OpenGraph {
 		$this->default_card = array_column( $tags, 'twitter:card' )[0] ?? null;

 		/**
-		 * Filters Open Graph metadata before output. Currently applied only to product pages.
+		 * Filters Open Graph metadata before output. Applied to product and shop pages.
 		 * Other page types may be supported later; callbacks must check the object type.
 		 * The first tag in each group is its root; details follow it. Remove or replace an entire group to omit or replace its details.
 		 *
@@ -135,6 +136,15 @@ class OpenGraph {
 			return $this->get_product_page_data();
 		}

+		if (
+			is_shop()
+			&& ! is_search()
+			&& ! is_paged()
+			&& ( ! isset( $_GET['product-page'] ) || 1 === absint( $_GET['product-page'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public pagination parameter.
+		) {
+			return $this->get_shop_page_data();
+		}
+
 		return null;
 	}

@@ -159,6 +169,23 @@ class OpenGraph {
 		);
 	}

+	/**
+	 * Build metadata for the configured shop page.
+	 *
+	 * @return array{tags: array<array<string, string>>, context: \WP_Post}|null
+	 */
+	private function get_shop_page_data(): ?array {
+		$page = get_post( wc_get_page_id( 'shop' ) );
+		if ( ! $page instanceof \WP_Post || 'publish' !== $page->post_status || '' !== $page->post_password ) {
+			return null;
+		}
+
+		return array(
+			'tags'    => wc_get_container()->get( ShopOpenGraphTags::class )->get_tags( $page ),
+			'context' => $page,
+		);
+	}
+
 	/**
 	 * Add metadata after the complete document is available.
 	 *
@@ -168,7 +195,7 @@ class OpenGraph {
 	 * @return mixed
 	 */
 	public function handle_wp_template_enhancement_output_buffer( $html ) {
-		if ( ! is_string( $html ) || ! $this->tags ) {
+		if ( ! is_string( $html ) || ! $this->tags || ComingSoonRequestHandler::is_showing_coming_soon() ) {
 			return $html;
 		}

diff --git a/plugins/woocommerce/src/Internal/StructuredData/OpenGraphDescription.php b/plugins/woocommerce/src/Internal/StructuredData/OpenGraphDescription.php
new file mode 100644
index 00000000000..ebc46394ad5
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/StructuredData/OpenGraphDescription.php
@@ -0,0 +1,32 @@
+<?php
+/**
+ * Open Graph description formatting.
+ *
+ * @package WooCommerce
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\StructuredData;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Cleans text for an Open Graph description.
+ *
+ * @internal
+ */
+class OpenGraphDescription {
+	/**
+	 * Remove markup and normalize whitespace.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param string $description Source text.
+	 * @return string
+	 */
+	public function clean( string $description ): string {
+		$description = wp_strip_all_tags( strip_shortcodes( $description ) );
+		return trim( (string) preg_replace( '/\s+/u', ' ', $description ) );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/StructuredData/OpenGraphImageTags.php b/plugins/woocommerce/src/Internal/StructuredData/OpenGraphImageTags.php
new file mode 100644
index 00000000000..09d0bce4cc5
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/StructuredData/OpenGraphImageTags.php
@@ -0,0 +1,54 @@
+<?php
+/**
+ * Open Graph image metadata.
+ *
+ * @package WooCommerce
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\StructuredData;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Builds metadata for an attachment image.
+ *
+ * @internal
+ */
+class OpenGraphImageTags {
+	/**
+	 * Build an image group, or return an empty array when the image is unavailable.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param int         $image_id Image attachment ID.
+	 * @param string|null $fallback_alt Fallback when the image has no alt text.
+	 * @param bool        $secure_url Whether to include the secure URL.
+	 * @return array<string, string>
+	 */
+	public function get_tags( int $image_id, ?string $fallback_alt = null, bool $secure_url = false ): array {
+		$image = wp_get_attachment_image_src( $image_id, 'full' );
+		if ( ! $image ) {
+			return array();
+		}
+
+		$tags = array( 'og:image' => $image[0] );
+		if ( $secure_url && 'https' === wp_parse_url( $image[0], PHP_URL_SCHEME ) ) {
+			$tags['og:image:secure_url'] = $image[0];
+		}
+		if ( $image[1] > 0 && $image[2] > 0 ) {
+			$tags['og:image:width']  = (string) $image[1];
+			$tags['og:image:height'] = (string) $image[2];
+		}
+		$alt = get_post_meta( $image_id, '_wp_attachment_image_alt', true );
+		if ( is_string( $alt ) && '' !== trim( $alt ) ) {
+			$tags['og:image:alt'] = $alt;
+		} elseif ( null !== $fallback_alt ) {
+			$tags['og:image:alt'] = $fallback_alt;
+		}
+		$tags['og:image:type'] = (string) get_post_mime_type( $image_id );
+
+		return $tags;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/StructuredData/ProductOpenGraphTags.php b/plugins/woocommerce/src/Internal/StructuredData/ProductOpenGraphTags.php
index 4240643cb10..b71a888d1ac 100644
--- a/plugins/woocommerce/src/Internal/StructuredData/ProductOpenGraphTags.php
+++ b/plugins/woocommerce/src/Internal/StructuredData/ProductOpenGraphTags.php
@@ -33,9 +33,7 @@ class ProductOpenGraphTags {
 	public function get_tags( WC_Product $product ): array {
 		$description = $product->get_short_description();
 		$description = '' !== $description ? $description : $product->get_description();
-		$description = wp_strip_all_tags( strip_shortcodes( $description ) );
-		// Collapse Unicode whitespace, including NBSP, which wp_strip_all_tags() leaves intact.
-		$description = trim( (string) preg_replace( '/\s+/u', ' ', $description ) );
+		$description = wc_get_container()->get( OpenGraphDescription::class )->clean( $description );
 		$tags        = array(
 			array( 'og:title' => $product->get_name() ),
 			array( 'og:type' => 'product' ),
@@ -55,7 +53,7 @@ class ProductOpenGraphTags {
 		}
 		$urls = array();
 		foreach ( $image_ids as $image_id ) {
-			$image = $this->get_image_tags( $image_id, $product->get_name() );
+			$image = wc_get_container()->get( OpenGraphImageTags::class )->get_tags( $image_id, $product->get_name() );
 			if ( $image && ! in_array( $image['og:image'], $urls, true ) ) {
 				$tags[] = $image;
 				$urls[] = $image['og:image'];
@@ -135,30 +133,6 @@ class ProductOpenGraphTags {
 		return $tags;
 	}

-	/**
-	 * Build metadata for an attachment and its image properties.
-	 *
-	 * @param int    $image_id Attachment ID.
-	 * @param string $title Fallback alternative text.
-	 * @return array<string, string>
-	 */
-	private function get_image_tags( int $image_id, string $title ): array {
-		$tags  = array();
-		$image = wp_get_attachment_image_src( $image_id, 'full' );
-		if ( $image ) {
-			$tags['og:image'] = $image[0];
-			if ( $image[1] > 0 && $image[2] > 0 ) {
-				$tags['og:image:width']  = (string) $image[1];
-				$tags['og:image:height'] = (string) $image[2];
-			}
-			$alt                   = get_post_meta( $image_id, '_wp_attachment_image_alt', true );
-			$tags['og:image:alt']  = is_string( $alt ) && '' !== trim( $alt ) ? $alt : $title;
-			$tags['og:image:type'] = (string) get_post_mime_type( $image_id );
-		}
-
-		return $tags;
-	}
-
 	/**
 	 * Build prices and sale dates for products with a single price.
 	 *
diff --git a/plugins/woocommerce/src/Internal/StructuredData/ShopOpenGraphTags.php b/plugins/woocommerce/src/Internal/StructuredData/ShopOpenGraphTags.php
new file mode 100644
index 00000000000..3879eaf1ae1
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/StructuredData/ShopOpenGraphTags.php
@@ -0,0 +1,48 @@
+<?php
+/**
+ * Shop Open Graph metadata.
+ *
+ * @package WooCommerce
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\StructuredData;
+
+use WP_Post;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Builds Open Graph metadata for the shop page.
+ *
+ * @internal
+ */
+class ShopOpenGraphTags {
+	/**
+	 * Build metadata from the configured shop page.
+	 *
+	 * @since 11.4.0
+	 * @param WP_Post $page Shop page.
+	 * @return array<array<string, string>>
+	 */
+	public function get_tags( WP_Post $page ): array {
+		$description = wc_get_container()->get( OpenGraphDescription::class )->clean( get_the_excerpt( $page ) );
+		$tags        = array(
+			array( 'og:title' => wp_strip_all_tags( get_the_title( $page ) ) ),
+			array( 'og:type' => 'website' ),
+			array( 'og:url' => get_permalink( $page ) ),
+			array( 'og:description' => wc_trim_string( $description ) ),
+			array( 'og:site_name' => get_bloginfo( 'name' ) ),
+		);
+
+		$image_id = get_post_thumbnail_id( $page );
+		$image    = $image_id ? wc_get_container()->get( OpenGraphImageTags::class )->get_tags( $image_id, null, true ) : array();
+		if ( $image ) {
+			$tags[] = $image;
+		}
+		$tags[] = array( 'twitter:card' => $image ? 'summary_large_image' : 'summary' );
+
+		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 405af27bb8e..3695e931b2c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StructuredData/OpenGraphTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StructuredData/OpenGraphTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Internal\StructuredData;

 use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonRequestHandler;
 use Automattic\WooCommerce\Internal\StructuredData\OpenGraph;
 use WC_Helper_Product;
 use WC_Product;
@@ -354,6 +355,239 @@ class OpenGraphTest extends WC_Unit_Test_Case {
 		$this->assertSame( array( 'USD' ), $tags['product:price:currency'] ?? array(), 'The filtered product currency should be rendered once.' );
 	}

+	/**
+	 * @testdox Should add shop metadata and preserve a provider's image.
+	 */
+	public function test_shop_metadata(): void {
+		$shop_id = self::factory()->post->create(
+			array(
+				'post_type'    => 'page',
+				'post_title'   => 'Coffee shop',
+				'post_status'  => 'publish',
+				'post_excerpt' => '<p>Fresh <strong>coffee</strong>.</p>',
+			)
+		);
+		$this->assertIsInt( $shop_id );
+		update_option( 'woocommerce_shop_page_id', $shop_id );
+		set_post_thumbnail( $shop_id, (int) $this->product->get_image_id() );
+		$shop_url = get_permalink( $shop_id );
+		$this->assertIsString( $shop_url );
+		$this->go_to( $shop_url );
+
+		$tags = $this->render_meta_tags( '<meta property="og:type" content="article">' );
+
+		$this->assertSame( array( 'Coffee shop' ), $tags['og:title'] );
+		$this->assertSame( array( 'website' ), $tags['og:type'] );
+		$this->assertSame( array( $shop_url ), $tags['og:url'] );
+		$this->assertSame( array( 'Fresh coffee.' ), $tags['og:description'] );
+		$this->assertSame( array( 'Test store' ), $tags['og:site_name'] );
+		$this->assertSame( array( wp_get_attachment_url( (int) $this->product->get_image_id() ) ), $tags['og:image'] );
+		$this->assertSame( array( '1200' ), $tags['og:image:width'] );
+		$this->assertSame( array( '800' ), $tags['og:image:height'] );
+		$this->assertSame( array( 'Product photo' ), $tags['og:image:alt'] );
+		$this->assertSame( array( 'summary_large_image' ), $tags['twitter:card'] );
+
+		$tags = $this->render_meta_tags( '<meta property="og:image" content="https://example.org/provider.jpg"><meta property="og:image:width" content="600">' );
+		$this->assertSame( array( 'https://example.org/provider.jpg' ), $tags['og:image'] );
+		$this->assertSame( array( '600' ), $tags['og:image:width'] );
+		$this->assertArrayNotHasKey( 'og:image:height', $tags );
+		$this->assertSame( array( 'summary_large_image' ), $tags['twitter:card'] );
+	}
+
+	/**
+	 * @testdox Should generate the shop description from page content when no excerpt is set.
+	 */
+	public function test_shop_description_uses_generated_excerpt(): void {
+		$shop_id = self::factory()->post->create(
+			array(
+				'post_type'    => 'page',
+				'post_status'  => 'publish',
+				'post_content' => '<p>Fresh <strong>coffee</strong>.</p>',
+				'post_excerpt' => '',
+			)
+		);
+		update_option( 'woocommerce_shop_page_id', $shop_id );
+		$this->go_to( get_permalink( $shop_id ) );
+
+		$tags = $this->render_meta_tags( '' );
+		$this->assertSame( array( 'Fresh coffee.' ), $tags['og:description'] ?? array() );
+	}
+
+	/**
+	 * @testdox Should not add shop metadata for excluded views.
+	 * @testWith ["search"]
+	 *           ["pagination"]
+	 *           ["protected"]
+	 *           ["unpublished"]
+	 *
+	 * @param string $view Excluded shop view.
+	 */
+	public function test_excludes_shop_views( string $view ): void {
+		$shop_id = self::factory()->post->create(
+			array(
+				'post_type'   => 'page',
+				'post_title'  => 'Shop',
+				'post_status' => 'publish',
+			)
+		);
+		update_option( 'woocommerce_shop_page_id', $shop_id );
+		$this->go_to( get_permalink( $shop_id ) );
+		$this->assertTrue( is_shop(), 'The request must be recognized as a shop view.' );
+
+		if ( 'search' === $view ) {
+			$GLOBALS['wp_query']->is_search = true;
+		} elseif ( 'pagination' === $view ) {
+			$GLOBALS['wp_query']->is_paged = true;
+		} elseif ( 'protected' === $view ) {
+			wp_update_post(
+				array(
+					'ID'            => $shop_id,
+					'post_password' => 'secret',
+				)
+			);
+		} else {
+			wp_update_post(
+				array(
+					'ID'          => $shop_id,
+					'post_status' => 'draft',
+				)
+			);
+		}
+
+		$this->assertSame( array(), $this->render_meta_tags( '' ), "The $view shop view must not output metadata." );
+	}
+
+	/**
+	 * @testdox Should add shop metadata regardless of query parameters.
+	 * @testWith ["woo-share"]
+	 *           ["min_price"]
+	 *           ["utm_source"]
+	 *
+	 * @param string $query_arg Query argument to check.
+	 */
+	public function test_shop_query_arguments( string $query_arg ): void {
+		$shop_id = self::factory()->post->create(
+			array(
+				'post_type'   => 'page',
+				'post_title'  => 'Shop',
+				'post_status' => 'publish',
+			)
+		);
+		$this->assertIsInt( $shop_id );
+		update_option( 'woocommerce_shop_page_id', $shop_id );
+		$shop_url = get_permalink( $shop_id );
+		$this->assertIsString( $shop_url );
+		$this->go_to( $shop_url );
+		$original_get = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Restore request state after the test.
+		$_GET         = array( $query_arg => 'test-value' );
+
+		try {
+			$tags = $this->render_meta_tags( '' );
+			$this->assertSame( array( $shop_url ), $tags['og:url'] );
+			$this->assertSame( array( 'summary' ), $tags['twitter:card'] );
+		} finally {
+			$_GET = $original_get;
+		}
+	}
+
+	/**
+	 * @testdox Should omit shop metadata on subsequent shortcode product pages.
+	 * @testWith ["1", true]
+	 *           ["2", false]
+	 *
+	 * @param string $page Product page number.
+	 * @param bool   $expected Whether metadata should be present.
+	 */
+	public function test_shop_shortcode_pagination( string $page, bool $expected ): void {
+		$shop_id = self::factory()->post->create(
+			array(
+				'post_type'   => 'page',
+				'post_title'  => 'Shop',
+				'post_status' => 'publish',
+			)
+		);
+		update_option( 'woocommerce_shop_page_id', $shop_id );
+		$this->go_to( get_permalink( $shop_id ) );
+		$original_get         = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Restore request state after the test.
+		$_GET['product-page'] = $page;
+
+		try {
+			$tags = $this->render_meta_tags( '' );
+			$this->assertSame( $expected, isset( $tags['og:title'] ) );
+		} finally {
+			$_GET = $original_get;
+		}
+	}
+
+	/**
+	 * @testdox Should only render shop metadata when coming-soon access is allowed.
+	 * @testWith ["", "", false]
+	 *           ["invalid", "", false]
+	 *           ["secret", "", true]
+	 *           ["", "invalid", false]
+	 *           ["", "secret", true]
+	 *
+	 * @param string $cookie  Share cookie value.
+	 * @param string $key     Share link key.
+	 * @param bool   $allowed Whether access is allowed.
+	 */
+	public function test_shop_metadata_respects_coming_soon_access( string $cookie, string $key, bool $allowed ): void {
+		$visibility = new \ReflectionProperty( ComingSoonRequestHandler::class, 'show_coming_soon' );
+		$visibility->setAccessible( true );
+		$original_visibility = $visibility->getValue();
+		$original_cookie     = $_COOKIE;
+		$original_get        = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Restore the request after the test.
+		$original_theme      = get_stylesheet();
+
+		try {
+			$visibility->setValue( null, null );
+			switch_theme( 'storefront' );
+			$this->assertFalse( wp_is_block_theme() );
+			wp_set_current_user( 0 );
+			$shop_id = self::factory()->post->create(
+				array(
+					'post_type'   => 'page',
+					'post_title'  => 'Private shop',
+					'post_status' => 'publish',
+				)
+			);
+			update_option( 'woocommerce_shop_page_id', $shop_id );
+			update_option( 'woocommerce_coming_soon', 'yes' );
+			update_option( 'woocommerce_store_pages_only', 'no' );
+			update_option( 'woocommerce_private_link', 'yes' );
+			update_option( 'woocommerce_share_key', 'secret' );
+			$_COOKIE['woo-share'] = $cookie;
+			$shop_url             = get_permalink( $shop_id );
+			$this->go_to( $key ? add_query_arg( 'woo-share', $key, $shop_url ) : $shop_url );
+			$_GET['woo-share'] = $key;
+			$this->sut->handle_template_redirect();
+			// PHPUnit has already sent output when the valid share link sets its cookie.
+			set_error_handler( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Suppress the expected cookie header warning in PHPUnit.
+				static function ( $severity, $message ) {
+					return E_WARNING === $severity && 0 === strpos( $message, 'Cannot modify header information' );
+				}
+			);
+			try {
+				wc_get_container()->get( ComingSoonRequestHandler::class )->handle_classic_template_include( '/original.php' );
+			} finally {
+				restore_error_handler();
+			}
+			$tags = $this->render_meta_tags( '' );
+
+			if ( $allowed ) {
+				$this->assertSame( array( 'Private shop' ), $tags['og:title'] ?? array() );
+				$this->assertSame( array( $shop_url ), $tags['og:url'] ?? array() );
+			} else {
+				$this->assertSame( array(), $tags, 'Blocked requests must not expose shop metadata.' );
+			}
+		} finally {
+			$visibility->setValue( null, $original_visibility );
+			$_COOKIE = $original_cookie;
+			$_GET    = $original_get;
+			switch_theme( $original_theme );
+		}
+	}
+
 	/**
 	 * Process a product document through the output filter and collect its metadata.
 	 *