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.
*