Commit 84060856d37 for woocommerce

commit 84060856d37c8decb611449d8ed0ba10504c2bde
Author: Bero <1199991+bgrgicak@users.noreply.github.com>
Date:   Thu Oct 29 14:07:23 2026 +0100

    Separate Open Graph output and use ordered tag groups (#70280)

    * Separate Open Graph page metadata from HTML output

    * Add changelog entry for Open Graph refactor

    * Refactor Open Graph page routing and product validation

    * Add changelog entry for product previews through share links

    * Document Open Graph description and locale patterns

    * Move shared Open Graph tag groups into the foundation

    * Use ordered tag groups for Open Graph metadata

    * Clarify valid Open Graph media roots

diff --git a/plugins/woocommerce/changelog/refactor-open-graph-metadata b/plugins/woocommerce/changelog/refactor-open-graph-metadata
new file mode 100644
index 00000000000..ffb0610521a
--- /dev/null
+++ b/plugins/woocommerce/changelog/refactor-open-graph-metadata
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Include product link previews when a coming-soon store is accessed through a valid share link.
diff --git a/plugins/woocommerce/src/Internal/StructuredData/OpenGraph.php b/plugins/woocommerce/src/Internal/StructuredData/OpenGraph.php
index 433fcf0d4fd..343dcb4fb4c 100644
--- a/plugins/woocommerce/src/Internal/StructuredData/OpenGraph.php
+++ b/plugins/woocommerce/src/Internal/StructuredData/OpenGraph.php
@@ -11,8 +11,6 @@ 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;

@@ -22,9 +20,9 @@ defined( 'ABSPATH' ) || exit;
 class OpenGraph {

 	/**
-	 * Metadata candidates keyed by property.
+	 * Ordered metadata groups, each containing related tags.
 	 *
-	 * @var array<string, string>
+	 * @var array<array<string, string>>
 	 */
 	private $tags = array();

@@ -57,20 +55,16 @@ class OpenGraph {
 		 *
 		 * @param bool $enabled Whether metadata is enabled.
 		 */
-		if ( ! apply_filters( 'woocommerce_enable_open_graph', true ) ) {
+		if ( ! apply_filters( 'woocommerce_enable_open_graph', true ) || is_preview() ) {
 			return;
 		}

-		if ( ! is_singular( 'product' ) || is_preview() || 'yes' === get_option( 'woocommerce_coming_soon' ) ) {
+		$page = $this->get_page_data();
+		if ( null === $page ) {
 			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 );
+		$this->tags = $this->prepare_tags( $page['tags'], $page['context'] );
 		if ( $this->tags ) {
 			add_filter( 'wp_template_enhancement_output_buffer', array( $this, 'handle_wp_template_enhancement_output_buffer' ), PHP_INT_MAX );
 		}
@@ -79,21 +73,21 @@ class OpenGraph {
 	/**
 	 * Filter and validate metadata before rendering starts.
 	 *
-	 * @param array<string, string>                           $tags   Metadata candidates.
+	 * @param array<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>
+	 * @return array<array<string, string>>
 	 */
 	private function prepare_tags( array $tags, $context ): array {
-		$this->default_card = $tags['twitter:card'] ?? null;
+		$this->default_card = array_column( $tags, 'twitter:card' )[0] ?? 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.
+		 * The first tag in each group is its root; details follow it. Remove or replace an entire group to omit or replace its details.
 		 *
 		 * @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 array<array<string, string>> $tags Ordered groups of og:, twitter:, or product: tags with string values. Removing the og:type group preserves an existing provider's type.
 		 * @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 );
@@ -101,257 +95,83 @@ class OpenGraph {
 			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 ) {
+		$prepared = array();
+		foreach ( $tags as $group ) {
+			if ( ! is_array( $group ) || ! $group ) {
 				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() ) {
+			$root = array_key_first( $group );
+			// Media details cannot start a group; the :url alias is also a valid root.
+			if ( ! is_string( $root ) || preg_match( '/^og:(image|video|audio):(?!url$)/D', $root ) ) {
 				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 ) ) {
+			foreach ( $group as $property => $value ) {
+				if ( ! is_string( $property ) || ! is_string( $value ) || '' === trim( $value ) || ! preg_match( '/^(og|twitter|product):[a-z_:]+$/D', $property ) ) {
+					unset( $group[ $property ] );
 					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 ( 'og:url' === $property || preg_match( '/^og:(image|video|audio)(:(url|secure_url))?$/D', $property ) ) {
+					$group[ $property ] = esc_url_raw( $value, array( 'http', 'https' ) );
+					if ( '' === $group[ $property ] ) {
+						unset( $group[ $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 ( isset( $group[ $root ] ) ) {
+				$prepared[] = $group;
 			}
 		}

-		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 );
+		return $prepared;
 	}

 	/**
-	 * Escape metadata and insert it at the end of the head.
+	 * Select metadata and its source object for the current page.
 	 *
-	 * @param string                $html Template output.
-	 * @param array<string, string> $tags Metadata to insert.
-	 * @return string
+	 * @return array{tags: array<array<string, string>>, context: WC_Product|\WP_Post|\WP_Term|\WP_Post_Type|null}|null
 	 */
-	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;
-			}
+	private function get_page_data(): ?array {
+		if ( is_product() ) {
+			return $this->get_product_page_data();
 		}

-		return $html;
+		return null;
 	}

 	/**
-	 * Read metadata property names from the current tag.
+	 * Build metadata for a published, unprotected product page.
 	 *
-	 * @param WP_HTML_Tag_Processor $processor HTML processor at a tag.
-	 * @return string[]
+	 * @return array{tags: array<array<string, string>>, context: WC_Product}|null
 	 */
-	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";
-			}
+	private function get_product_page_data(): ?array {
+		$product = wc_get_product( get_queried_object_id() );
+		if (
+			! $product instanceof WC_Product
+			|| ProductStatus::PUBLISH !== $product->get_status()
+			|| '' !== $product->get_post_password()
+		) {
+			return null;
 		}

-		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 ),
+		return array(
+			'tags'    => wc_get_container()->get( ProductOpenGraphTags::class )->get_tags( $product ),
+			'context' => $product,
 		);
-		$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.
+	 * Add metadata after the complete document is available.
 	 *
-	 * @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.
+	 * @internal
 	 *
-	 * @param int    $image_id Attachment ID.
-	 * @param string $title Fallback alternative text.
-	 * @return array<string, string>
+	 * @param mixed $html Filtered template output.
+	 * @return mixed
 	 */
-	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 );
+	public function handle_wp_template_enhancement_output_buffer( $html ) {
+		if ( ! is_string( $html ) || ! $this->tags ) {
+			return $html;
 		}

-		return $tags;
+		return wc_get_container()->get( OpenGraphMarkup::class )->merge( $html, $this->tags, $this->default_card );
 	}
 }
diff --git a/plugins/woocommerce/src/Internal/StructuredData/OpenGraphMarkup.php b/plugins/woocommerce/src/Internal/StructuredData/OpenGraphMarkup.php
new file mode 100644
index 00000000000..4277ed8a18a
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/StructuredData/OpenGraphMarkup.php
@@ -0,0 +1,275 @@
+<?php
+/**
+ * Product Open Graph metadata.
+ *
+ * @package WooCommerce
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\StructuredData;
+
+use WP_HTML_Tag_Processor;
+use WP_HTML_Text_Replacement;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Fills missing Open Graph metadata in a rendered document.
+ *
+ * @internal
+ */
+class OpenGraphMarkup {
+
+	/**
+	 * Add metadata only after inspecting the rendered head for existing tags.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param string                       $html         Template output.
+	 * @param array<array<string, string>> $candidates   Validated metadata.
+	 * @param string|null                  $default_card Twitter card before filtering.
+	 * @return string
+	 */
+	public function merge( string $html, array $candidates, ?string $default_card ): string {
+		if ( ! $candidates ) {
+			return $html;
+		}
+
+		/**
+		 * Metadata ownership rules.
+		 *
+		 * @var array<string, array{detect?: string, match: string, exists: bool}> $tag_groups
+		 */
+		$tag_groups = array(
+			'image' => array(
+				'detect' => '/^og:image(?::url)?$/D',
+				'match'  => '/^og:image(?::|$)/D',
+				'exists' => false,
+			),
+		);
+
+		$processor = new WP_HTML_Tag_Processor( $html );
+		$tags      = $candidates;
+		$values    = array();
+		foreach ( $candidates as $group ) {
+			$values += $group;
+		}
+		$existing       = array();
+		$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( $values['og:type'] ) ) {
+					$processor->set_attribute( 'content', html_entity_decode( $values['og:type'], ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
+				} elseif ( ! is_string( $content ) || '' === trim( $content ) ) {
+					continue;
+				}
+				foreach ( $tag_groups as $group_name => $group ) {
+					if ( preg_match( $group['detect'] ?? $group['match'], $property ) ) {
+						$tag_groups[ $group_name ]['exists'] = true;
+					}
+				}
+				$existing[ $property ] = true;
+			}
+		}
+
+		// Keep related metadata with the provider that supplied it.
+		foreach ( $tags as $index => $candidate ) {
+			if ( isset( $existing[ array_key_first( $candidate ) ] ) ) {
+				unset( $tags[ $index ] );
+				continue;
+			}
+			foreach ( $candidate as $property => $value ) {
+				foreach ( $tag_groups as $group ) {
+					if ( $group['exists'] && preg_match( $group['match'], $property ) ) {
+						unset( $tags[ $index ] );
+						continue 3;
+					}
+				}
+			}
+		}
+
+		if ( ! $head_complete ) {
+			return $html;
+		}
+
+		foreach ( $tags as $index => $group ) {
+			if ( isset( $group['twitter:card'] ) && $group['twitter:card'] === $default_card ) {
+				$has_image                      = $tag_groups['image']['exists'] || isset( $values['og:image'] ) || isset( $values['og:image:url'] );
+				$tags[ $index ]['twitter:card'] = $has_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<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 );
+			}
+
+			/**
+			 * Remove the current tag.
+			 *
+			 * @internal
+			 */
+			public function remove_tag(): void {
+				$this->set_bookmark( 'here' );
+				$this->lexical_updates[] = new WP_HTML_Text_Replacement( $this->bookmarks['here']->start, $this->bookmarks['here']->length, '' );
+			}
+		};
+
+		$media_roots = array();
+		foreach ( $tags as $group ) {
+			$root = array_key_first( $group );
+			if ( in_array( $root, array( 'og:image', 'og:video', 'og:audio' ), true ) ) {
+				$media_roots[ $root ] = true;
+			}
+		}
+		$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();
+			}
+			$properties = self::get_meta_properties( $inserter );
+			if ( $media_roots ) {
+				// Rebuild empty media roots and orphaned details together in group order.
+				$remaining = array_filter(
+					$properties,
+					static function ( string $property ) use ( $media_roots ): bool {
+						foreach ( $media_roots as $root => $unused ) {
+							if ( $root === $property || 0 === strpos( $property, $root . ':' ) ) {
+								return false;
+							}
+						}
+						return true;
+					}
+				);
+				if ( $properties !== $remaining ) {
+					if ( ! $remaining ) {
+						$inserter->remove_tag();
+						continue;
+					}
+					foreach ( array( 'property', 'name' ) as $attribute ) {
+						$value = $inserter->get_attribute( $attribute );
+						if ( is_string( $value ) ) {
+							$names = preg_split( '/\s+/', strtolower( trim( $value ) ) );
+							$names = false !== $names ? array_intersect( $names, $remaining ) : array();
+							if ( $names ) {
+								$inserter->set_attribute( $attribute, implode( ' ', $names ) );
+							} else {
+								$inserter->remove_attribute( $attribute );
+							}
+						}
+					}
+					$properties = $remaining;
+				}
+			}
+			$content = $inserter->get_attribute( 'content' );
+			if ( is_string( $content ) && '' !== trim( $content ) ) {
+				continue;
+			}
+			foreach ( $properties as $property ) {
+				foreach ( $tags as $index => $group ) {
+					if ( isset( $group[ $property ] ) ) {
+						// Decode existing entities because set_attribute() always encodes ampersands.
+						$inserter->set_attribute( 'content', html_entity_decode( $group[ $property ], ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
+						unset( $tags[ $index ][ $property ] );
+						if ( ! $tags[ $index ] ) {
+							unset( $tags[ $index ] );
+						}
+						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<array<string, string>> $tags Metadata to render.
+	 * @return string
+	 */
+	private static function render_tags( array $tags ): string {
+		$markup = '';
+		foreach ( $tags as $group ) {
+			foreach ( $group as $property => $value ) {
+				$is_url  = 'og:url' === $property || preg_match( '/^og:(image|video|audio)(:(url|secure_url))?$/D', $property );
+				$content = $is_url ? esc_url( $value, array( 'http', 'https' ) ) : 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;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/StructuredData/ProductOpenGraphTags.php b/plugins/woocommerce/src/Internal/StructuredData/ProductOpenGraphTags.php
new file mode 100644
index 00000000000..bd63d2ed65d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/StructuredData/ProductOpenGraphTags.php
@@ -0,0 +1,94 @@
+<?php
+/**
+ * Product Open Graph metadata.
+ *
+ * @package WooCommerce
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\StructuredData;
+
+use WC_Product;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Builds Open Graph metadata for a product page.
+ *
+ * @internal
+ */
+class ProductOpenGraphTags {
+
+	/**
+	 * Build metadata from the product and its featured image.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param WC_Product $product Product being shared.
+	 * @return array<array<string, string>>
+	 */
+	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 ) );
+		$tags        = array(
+			array( 'og:title' => $product->get_name() ),
+			array( 'og:type' => 'product' ),
+			array( 'og:url' => $product->get_permalink() ),
+			array( 'og:description' => wc_trim_string( $description ) ),
+		);
+		foreach ( $this->get_site_tags() as $property => $value ) {
+			$tags[] = array( $property => $value );
+		}
+		$image = $this->get_image_tags( (int) $product->get_image_id(), $product->get_name() );
+		if ( $image ) {
+			$tags[] = $image;
+		}
+		$tags[] = array( 'twitter:card' => $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();
+		// Accept only language_TERRITORY locales, such as en_US.
+		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
index e7de7c31517..7c608465768 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StructuredData/OpenGraphTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StructuredData/OpenGraphTest.php
@@ -164,10 +164,16 @@ class OpenGraphTest extends WC_Unit_Test_Case {
 				$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'] );
+				foreach ( $tags as $index => $group ) {
+					if ( isset( $group['og:description'] ) || isset( $group['og:type'] ) ) {
+						unset( $tags[ $index ] );
+					} elseif ( isset( $group['og:title'] ) ) {
+						$tags[ $index ]['og:title'] = 'Custom & "title"';
+					} elseif ( isset( $group['twitter:card'] ) ) {
+						$tags[ $index ]['twitter:card'] = 'summary';
+					}
+				}
+				$tags[] = array( 'twitter:title' => 'Twitter title' );

 				return $tags;
 			},
@@ -191,8 +197,16 @@ class OpenGraphTest extends WC_Unit_Test_Case {
 		add_filter(
 			'woocommerce_open_graph_tags',
 			static function ( $tags ) {
-				$tags['product:price:amount']   = '19.99';
-				$tags['product:price:currency'] = 'USD';
+				foreach ( $tags as $index => $group ) {
+					unset( $group['product:price:amount'], $group['product:price:currency'] );
+					if ( $group ) {
+						$tags[ $index ] = $group;
+					} else {
+						unset( $tags[ $index ] );
+					}
+				}
+				$tags[] = array( 'product:price:amount' => '19.99' );
+				$tags[] = array( 'product:price:currency' => 'USD' );

 				return $tags;
 			}