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