Commit cbf68adad2c for woocommerce

commit cbf68adad2c9a693b3b609e35fddf9f02d36bee5
Author: Bero <1199991+bgrgicak@users.noreply.github.com>
Date:   Fri Oct 23 09:30:17 2026 +0200

    Add Open Graph support to WooCommerce product pages (#70077)

    * Add Open Graph metadata for product link previews

    * Add changelog entry for product link previews

    * Add product Open Graph metadata and filter tests

    * Fix dimensions when replacing Open Graph images

    * Fix duplicate Open Graph tags after stray head markup

    * Allow product metadata in the Open Graph tags filter

    * Update changelog with Open Graph type opt-out

    * Add fallback product price metadata

    * Update changelog for product price previews

    * Remove automatic product price metadata

    * Restore changelog scope to product link previews

    * Fix double escaping when filling Open Graph tags

    * Fix stale image details and conflicting Open Graph types

    * Fix empty Open Graph image URL aliases

diff --git a/plugins/woocommerce/changelog/add-product-open-graph b/plugins/woocommerce/changelog/add-product-open-graph
new file mode 100644
index 00000000000..56868e0e6ef
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-product-open-graph
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add product link previews alongside SEO plugins; unset og:type via woocommerce_open_graph_tags to preserve existing types.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index e7c1d2ad15c..4b80f06c88e 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -50,6 +50,7 @@ use Automattic\WooCommerce\Internal\Caches\ProductVersionStringInvalidator;
 use Automattic\WooCommerce\Internal\Caches\OrdersVersionStringInvalidator;
 use Automattic\WooCommerce\Internal\Caches\TaxRateVersionStringInvalidator;
 use Automattic\WooCommerce\Internal\CustomerEmailVerification\CustomerEmailVerification;
+use Automattic\WooCommerce\Internal\StructuredData\OpenGraph;

 /**
  * Main WooCommerce Class.
@@ -415,6 +416,7 @@ final class WooCommerce {
 		$container->get( OrderLogsCleanupHelper::class );
 		$container->get( PaymentSettingsScreen::class );
 		$container->get( AbilitiesTracking::class );
+		$container->get( OpenGraph::class );

 		/**
 		 * These classes have a register method for attaching hooks.
diff --git a/plugins/woocommerce/src/Internal/StructuredData/OpenGraph.php b/plugins/woocommerce/src/Internal/StructuredData/OpenGraph.php
new file mode 100644
index 00000000000..433fcf0d4fd
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/StructuredData/OpenGraph.php
@@ -0,0 +1,357 @@
+<?php
+/**
+ * Product Open Graph metadata.
+ *
+ * @package WooCommerce
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\StructuredData;
+
+use Automattic\WooCommerce\Enums\ProductStatus;
+use WC_Product;
+use WP_HTML_Tag_Processor;
+use WP_HTML_Text_Replacement;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Provides product link previews and fills missing Open Graph metadata.
+ */
+class OpenGraph {
+
+	/**
+	 * Metadata candidates keyed by property.
+	 *
+	 * @var array<string, string>
+	 */
+	private $tags = array();
+
+	/**
+	 * Default Twitter card before filtering, or null when omitted.
+	 *
+	 * @var string|null
+	 */
+	private $default_card = null;
+
+	/**
+	 * Register the frontend callback.
+	 *
+	 * @internal
+	 */
+	public function __construct() {
+		add_action( 'template_redirect', array( $this, 'handle_template_redirect' ) );
+	}
+
+	/**
+	 * Prepare metadata for a public, unprotected product before rendering the template.
+	 *
+	 * @internal
+	 */
+	public function handle_template_redirect(): void {
+		/**
+		 * Filters whether WooCommerce may output Open Graph metadata.
+		 *
+		 * @since 11.4.0
+		 *
+		 * @param bool $enabled Whether metadata is enabled.
+		 */
+		if ( ! apply_filters( 'woocommerce_enable_open_graph', true ) ) {
+			return;
+		}
+
+		if ( ! is_singular( 'product' ) || is_preview() || 'yes' === get_option( 'woocommerce_coming_soon' ) ) {
+			return;
+		}
+
+		$product = wc_get_product( get_queried_object_id() );
+		if ( ! $product instanceof WC_Product || ProductStatus::PUBLISH !== $product->get_status() || '' !== $product->get_post_password() ) {
+			return;
+		}
+
+		$this->tags = $this->prepare_tags( $this->get_product_tags( $product ), $product );
+		if ( $this->tags ) {
+			add_filter( 'wp_template_enhancement_output_buffer', array( $this, 'handle_wp_template_enhancement_output_buffer' ), PHP_INT_MAX );
+		}
+	}
+
+	/**
+	 * Filter and validate metadata before rendering starts.
+	 *
+	 * @param array<string, string>                           $tags   Metadata candidates.
+	 * @param WC_Product|\WP_Post|\WP_Term|\WP_Post_Type|null $context Product or queried object; null when no object is available.
+	 * @return array<string, string>
+	 */
+	private function prepare_tags( array $tags, $context ): array {
+		$this->default_card = $tags['twitter:card'] ?? null;
+
+		/**
+		 * Filters Open Graph metadata before output. Currently applied only to product pages.
+		 * Other page types may be supported later; callbacks must check the object type.
+		 * Removing og:type from $tags also preserves an existing provider's type.
+		 *
+		 * @since 11.4.0
+		 *
+		 * @param array<string, string> $tags Metadata keyed by og:, twitter:, or product: property name. Remove a key to omit a tag.
+		 * @param WC_Product|\WP_Post|\WP_Term|\WP_Post_Type|null $context Product or queried object; null when no object is available.
+		 */
+		$tags = apply_filters( 'woocommerce_open_graph_tags', $tags, $context );
+		if ( ! is_array( $tags ) ) {
+			return array();
+		}
+
+		foreach ( $tags as $property => $value ) {
+			if ( ! is_string( $property ) || ! is_string( $value ) || '' === trim( $value ) || ! preg_match( '/^(og|twitter|product):[a-z_:]+$/', $property ) ) {
+				unset( $tags[ $property ] );
+				continue;
+			}
+			if ( in_array( $property, array( 'og:url', 'og:image' ), true ) ) {
+				$tags[ $property ] = esc_url_raw( $value );
+				if ( '' === $tags[ $property ] ) {
+					unset( $tags[ $property ] );
+				}
+			}
+		}
+
+		return $tags;
+	}
+
+	/**
+	 * Add metadata only after inspecting the rendered head for existing tags.
+	 *
+	 * @internal
+	 *
+	 * @param mixed $html Filtered template output.
+	 * @return mixed Template output, with WooCommerce metadata added when appropriate.
+	 */
+	public function handle_wp_template_enhancement_output_buffer( $html ) {
+		if ( ! is_string( $html ) || ! $this->tags ) {
+			return $html;
+		}
+
+		$processor      = new WP_HTML_Tag_Processor( $html );
+		$tags           = $this->tags;
+		$has_image      = false;
+		$head_complete  = false;
+		$template_depth = 0;
+
+		while ( $processor->next_tag( array( 'tag_closers' => 'visit' ) ) ) {
+			if ( 'TEMPLATE' === $processor->get_tag() ) {
+				$template_depth = max( 0, $template_depth + ( $processor->is_tag_closer() ? -1 : 1 ) );
+			}
+			if ( $template_depth > 0 ) {
+				continue;
+			}
+			// The HTML spec allows the closing head tag to be omitted, so we also check for the body tag to determine head completion.
+			if ( ( 'HEAD' === $processor->get_tag() && $processor->is_tag_closer() ) || ( 'BODY' === $processor->get_tag() && ! $processor->is_tag_closer() ) ) {
+				$head_complete = true;
+				break;
+			}
+
+			if ( 'META' !== $processor->get_tag() ) {
+				continue;
+			}
+
+			foreach ( self::get_meta_properties( $processor ) as $property ) {
+				$content = $processor->get_attribute( 'content' );
+				if ( 'og:type' === $property && isset( $this->tags['og:type'] ) ) {
+					$processor->set_attribute( 'content', html_entity_decode( $this->tags['og:type'], ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
+				} elseif ( ! is_string( $content ) || '' === trim( $content ) ) {
+					continue;
+				}
+				if ( in_array( $property, array( 'og:image', 'og:image:url' ), true ) ) {
+					$has_image = true;
+				}
+				if ( ! isset( $tags['og:image'] ) || 0 !== strpos( $property, 'og:image:' ) ) {
+					unset( $tags[ $property ] );
+				}
+			}
+		}
+
+		if ( $has_image ) {
+			// WooCommerce's image details may not describe the existing provider's image.
+			foreach ( array_keys( $tags ) as $property ) {
+				if ( 'og:image' === $property || 0 === strpos( $property, 'og:image:' ) ) {
+					unset( $tags[ $property ] );
+				}
+			}
+		}
+
+		if ( ! $head_complete ) {
+			return $html;
+		}
+
+		if ( isset( $tags['twitter:card'] ) && $tags['twitter:card'] === $this->default_card ) {
+			$tags['twitter:card'] = $has_image || ! empty( $tags['og:image'] ) ? 'summary_large_image' : 'summary';
+		}
+
+		return self::insert_tags( $processor->get_updated_html(), $tags );
+	}
+
+	/**
+	 * Escape metadata and insert it at the end of the head.
+	 *
+	 * @param string                $html Template output.
+	 * @param array<string, string> $tags Metadata to insert.
+	 * @return string
+	 */
+	private static function insert_tags( string $html, array $tags ): string {
+		// Match core's insertion pattern until the HTML API provides a public insertion method.
+		$inserter = new class( $html ) extends WP_HTML_Tag_Processor {
+			/**
+			 * Insert markup before the current tag.
+			 *
+			 * @param string $markup HTML to insert.
+			 */
+			public function insert_before( string $markup ): void {
+				$this->set_bookmark( 'here' );
+				$this->lexical_updates[] = new WP_HTML_Text_Replacement( $this->bookmarks['here']->start, 0, $markup );
+			}
+		};
+
+		$replace_image_details         = isset( $tags['og:image'] );
+		$image_details                 = array_filter(
+			$tags,
+			static function ( string $property ): bool {
+				return 0 === strpos( $property, 'og:image:' );
+			},
+			ARRAY_FILTER_USE_KEY
+		);
+		$image_details['og:image:url'] = $image_details['og:image:url'] ?? $tags['og:image'] ?? '';
+		$template_depth                = 0;
+		while ( $inserter->next_tag( array( 'tag_closers' => 'visit' ) ) ) {
+			if ( 'TEMPLATE' === $inserter->get_tag() ) {
+				$template_depth = max( 0, $template_depth + ( $inserter->is_tag_closer() ? -1 : 1 ) );
+			}
+			if ( $template_depth > 0 ) {
+				continue;
+			}
+			if ( ( 'HEAD' === $inserter->get_tag() && $inserter->is_tag_closer() ) || ( 'BODY' === $inserter->get_tag() && ! $inserter->is_tag_closer() ) ) {
+				$inserter->insert_before( self::render_tags( $tags ) );
+				return $inserter->get_updated_html();
+			}
+			$content = $inserter->get_attribute( 'content' );
+			foreach ( self::get_meta_properties( $inserter ) as $property ) {
+				if ( $replace_image_details && 0 === strpos( $property, 'og:image:' ) ) {
+					$inserter->set_attribute( 'content', html_entity_decode( $image_details[ $property ] ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
+					unset( $tags[ $property ] );
+				} elseif ( isset( $tags[ $property ] ) && ( ! is_string( $content ) || '' === trim( $content ) ) ) {
+					// Decode existing entities because set_attribute() always encodes ampersands.
+					$inserter->set_attribute( 'content', html_entity_decode( $tags[ $property ], ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
+					unset( $tags[ $property ] );
+				}
+			}
+			if ( 'BODY' === $inserter->get_tag() ) {
+				break;
+			}
+		}
+
+		return $html;
+	}
+
+	/**
+	 * Read metadata property names from the current tag.
+	 *
+	 * @param WP_HTML_Tag_Processor $processor HTML processor at a tag.
+	 * @return string[]
+	 */
+	private static function get_meta_properties( WP_HTML_Tag_Processor $processor ): array {
+		$properties = array();
+		if ( 'META' !== $processor->get_tag() ) {
+			return $properties;
+		}
+		foreach ( array( 'property', 'name' ) as $attribute ) {
+			$value = $processor->get_attribute( $attribute );
+			if ( is_string( $value ) ) {
+				$names = preg_split( '/\s+/', strtolower( trim( $value ) ) );
+				if ( false !== $names ) {
+					$properties = array_merge( $properties, $names );
+				}
+			}
+		}
+		return $properties;
+	}
+
+	/**
+	 * Escape metadata for output in the head.
+	 *
+	 * @param array<string, string> $tags Metadata to render.
+	 * @return string
+	 */
+	private static function render_tags( array $tags ): string {
+		$markup = '';
+		foreach ( $tags as $property => $value ) {
+			$content = in_array( $property, array( 'og:url', 'og:image' ), true ) ? esc_url( $value ) : esc_attr( $value );
+			if ( '' !== $content ) {
+				$attribute = 0 === strpos( $property, 'twitter:' ) ? 'name' : 'property';
+				$markup   .= '<meta ' . esc_attr( $attribute ) . '="' . esc_attr( $property ) . '" content="' . $content . '" />' . "\n";
+			}
+		}
+
+		return $markup;
+	}
+
+	/**
+	 * Build metadata from the product and its featured image.
+	 *
+	 * @param WC_Product $product Product being shared.
+	 * @return array<string, string>
+	 */
+	private function get_product_tags( WC_Product $product ): array {
+		$description          = $product->get_short_description();
+		$description          = '' !== $description ? $description : $product->get_description();
+		$description          = wp_strip_all_tags( strip_shortcodes( $description ) );
+		$description          = trim( (string) preg_replace( '/\s+/u', ' ', $description ) );
+		$tags                 = array(
+			'og:title'       => $product->get_name(),
+			'og:type'        => 'product',
+			'og:url'         => $product->get_permalink(),
+			'og:description' => wc_trim_string( $description ),
+		);
+		$tags                += $this->get_site_tags();
+		$tags                += $this->get_image_tags( (int) $product->get_image_id(), $product->get_name() );
+		$tags['twitter:card'] = isset( $tags['og:image'] ) ? 'summary_large_image' : 'summary';
+
+		return $tags;
+	}
+
+	/**
+	 * Build metadata shared by all supported pages.
+	 *
+	 * @return array<string, string>
+	 */
+	private function get_site_tags(): array {
+		$tags   = array( 'og:site_name' => get_bloginfo( 'name' ) );
+		$locale = get_locale();
+		if ( preg_match( '/^[a-z]{2,3}_[A-Z]{2}$/', $locale ) ) {
+			$tags['og:locale'] = $locale;
+		}
+
+		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;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/StructuredData/OpenGraphTest.php b/plugins/woocommerce/tests/php/src/Internal/StructuredData/OpenGraphTest.php
new file mode 100644
index 00000000000..e7de7c31517
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/StructuredData/OpenGraphTest.php
@@ -0,0 +1,228 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\StructuredData;
+
+use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Internal\StructuredData\OpenGraph;
+use WC_Helper_Product;
+use WC_Product;
+use WC_Unit_Test_Case;
+use WP_HTML_Tag_Processor;
+
+/**
+ * Tests for product Open Graph metadata.
+ */
+class OpenGraphTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var OpenGraph
+	 */
+	private $sut;
+
+	/**
+	 * Product whose page is being rendered.
+	 *
+	 * @var WC_Product
+	 */
+	private $product;
+
+	/**
+	 * Request URI before navigating to the product.
+	 *
+	 * @var string|null
+	 */
+	private $request_uri;
+
+	/**
+	 * Set up a published product with a featured image.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut         = new OpenGraph();
+		$this->request_uri = $_SERVER['REQUEST_URI'] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Preserve request state changed by go_to().
+
+		update_option( 'woocommerce_coming_soon', 'no' );
+		update_option( 'blogname', 'Test store' );
+
+		$image_id = self::factory()->attachment->create(
+			array(
+				'file'           => 'product.jpg',
+				'post_mime_type' => 'image/jpeg',
+			)
+		);
+		$this->assertIsInt( $image_id, 'The featured image fixture should be created successfully.' );
+		wp_update_attachment_metadata(
+			$image_id,
+			array(
+				'file'   => 'product.jpg',
+				'width'  => 1200,
+				'height' => 800,
+			)
+		);
+		update_post_meta( $image_id, '_wp_attachment_image_alt', 'Product photo' );
+
+		$this->product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'name'              => 'Coffee & "cream"',
+				'status'            => ProductStatus::PUBLISH,
+				'short_description' => '<p>A <strong>smooth</strong> blend.</p>',
+				'image_id'          => $image_id,
+			)
+		);
+		$this->go_to( $this->product->get_permalink() );
+	}
+
+	/**
+	 * Restore request state that WordPress does not reset.
+	 */
+	public function tearDown(): void {
+		try {
+			if ( null === $this->request_uri ) {
+				unset( $_SERVER['REQUEST_URI'] );
+			} else {
+				$_SERVER['REQUEST_URI'] = $this->request_uri;
+			}
+		} finally {
+			parent::tearDown();
+		}
+	}
+
+	/**
+	 * @testdox Should add missing product metadata and fill empty tags without duplicates.
+	 */
+	public function test_adds_missing_and_fills_empty_tags(): void {
+		$tags = $this->render_meta_tags( '<meta property="og:description" content=" ">' );
+
+		$this->assertSame( array( 'Coffee & "cream"' ), $tags['og:title'], 'The product title should survive HTML escaping.' );
+		$this->assertSame( array( 'product' ), $tags['og:type'], 'Missing Open Graph type should be added.' );
+		$this->assertSame( array( $this->product->get_permalink() ), $tags['og:url'], 'The product permalink should be used.' );
+		$this->assertSame( array( 'A smooth blend.' ), $tags['og:description'], 'The empty description should be filled once with plain text.' );
+		$this->assertSame( array( 'Test store' ), $tags['og:site_name'], 'The store name should be added.' );
+		$this->assertSame( array( wp_get_attachment_url( (int) $this->product->get_image_id() ) ), $tags['og:image'], 'The featured image should be added.' );
+		$this->assertSame( array( '1200' ), $tags['og:image:width'], 'Image width should describe the featured image.' );
+		$this->assertSame( array( '800' ), $tags['og:image:height'], 'Image height should describe the featured image.' );
+		$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.' );
+	}
+
+	/**
+	 * @testdox Should replace an existing Open Graph type with product without adding a duplicate.
+	 */
+	public function test_overrides_existing_type(): void {
+		$tags = $this->render_meta_tags( '<meta property="og:type" content="article">' );
+
+		$this->assertSame( array( 'product' ), $tags['og:type'], 'The existing type should be replaced with a single product tag.' );
+	}
+
+	/**
+	 * @testdox Should preserve existing metadata without mixing in featured image details.
+	 */
+	public function test_preserves_existing_metadata(): void {
+		$tags = $this->render_meta_tags(
+			'<meta property="og:title" content="SEO title">
+			<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">'
+		);
+
+		$this->assertSame( array( 'SEO title' ), $tags['og:title'], 'An existing title should be preserved without duplication.' );
+		$this->assertSame( array( 'SEO description' ), $tags['og:description'], 'An existing description should be preserved without duplication.' );
+		$this->assertSame( array( 'https://example.org/seo.jpg' ), $tags['og:image'], 'An existing image should be preserved without duplication.' );
+		$this->assertSame( array( '600' ), $tags['og:image:width'], 'Existing image dimensions should be preserved.' );
+		$this->assertArrayNotHasKey( 'og:image:height', $tags, 'The featured image height should not describe another provider\'s image.' );
+		$this->assertArrayNotHasKey( 'og:image:alt', $tags, 'The featured image alternative text should not describe another provider\'s image.' );
+		$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.' );
+	}
+
+	/**
+	 * @testdox Should allow disabling Open Graph output without changing existing metadata.
+	 */
+	public function test_enable_filter_disables_output(): void {
+		add_filter( 'woocommerce_enable_open_graph', '__return_false' );
+
+		$tags = $this->render_meta_tags( '<meta property="og:type" content="article">' );
+
+		$this->assertCount( 1, $tags, 'No metadata should be added when Open Graph output is disabled.' );
+		$this->assertSame( array( 'article' ), $tags['og:type'], 'Existing metadata should remain unchanged when output is disabled.' );
+	}
+
+	/**
+	 * @testdox Should pass the product to the tags filter and honor added, changed, and removed tags.
+	 */
+	public function test_tags_filter_customizes_output(): void {
+		add_filter(
+			'woocommerce_open_graph_tags',
+			function ( $tags, $context ) {
+				$this->assertInstanceOf( WC_Product::class, $context, 'The filter context should be a product.' );
+				$this->assertSame( $this->product->get_id(), $context->get_id(), 'The filter should receive the queried product.' );
+
+				$tags['og:title']      = 'Custom & "title"';
+				$tags['twitter:title'] = 'Twitter title';
+				$tags['twitter:card']  = 'summary';
+				unset( $tags['og:description'], $tags['og:type'] );
+
+				return $tags;
+			},
+			10,
+			2
+		);
+
+		$tags = $this->render_meta_tags( '<meta property="og:type" content="article">' );
+
+		$this->assertSame( array( 'Custom & "title"' ), $tags['og:title'], 'The filtered title should be rendered and escaped correctly.' );
+		$this->assertSame( array( 'Twitter title' ), $tags['twitter:title'], 'New tags supplied by the filter should be rendered.' );
+		$this->assertSame( array( 'summary' ), $tags['twitter:card'], 'The filtered Twitter card should be kept even when the product has an image.' );
+		$this->assertArrayNotHasKey( 'og:description', $tags, 'Removed tags should not be rendered.' );
+		$this->assertSame( array( 'article' ), $tags['og:type'], 'Removing the type candidate should preserve the existing provider\'s type.' );
+	}
+
+	/**
+	 * @testdox Should render product metadata supplied through the tags filter.
+	 */
+	public function test_tags_filter_adds_product_metadata(): void {
+		add_filter(
+			'woocommerce_open_graph_tags',
+			static function ( $tags ) {
+				$tags['product:price:amount']   = '19.99';
+				$tags['product:price:currency'] = 'USD';
+
+				return $tags;
+			}
+		);
+
+		$tags = $this->render_meta_tags( '' );
+
+		$this->assertSame( array( '19.99' ), $tags['product:price:amount'] ?? array(), 'The filtered product price should be rendered once.' );
+		$this->assertSame( array( 'USD' ), $tags['product:price:currency'] ?? array(), 'The filtered product currency should be rendered once.' );
+	}
+
+	/**
+	 * Process a product document through the output filter and collect its metadata.
+	 *
+	 * @param string $head Existing head markup.
+	 * @return array<string, array<string|bool|null>> Metadata values grouped by property to retain duplicates.
+	 */
+	private function render_meta_tags( string $head ): array {
+		$this->sut->handle_template_redirect();
+		$html      = apply_filters( 'wp_template_enhancement_output_buffer', '<!DOCTYPE html><html><head>' . $head . '</head><body></body></html>' );
+		$processor = new WP_HTML_Tag_Processor( $html );
+		$tags      = array();
+
+		while ( $processor->next_tag( array( 'tag_name' => 'META' ) ) ) {
+			$property = $processor->get_attribute( 'property' ) ?? $processor->get_attribute( 'name' );
+			if ( is_string( $property ) ) {
+				$tags[ $property ][] = $processor->get_attribute( 'content' );
+			}
+		}
+
+		return $tags;
+	}
+}