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;
+ }
+}