Commit edee2521a48 for woocommerce
commit edee2521a4850a767ac95dd6ee900a93c2915ae8
Author: Allison Levine <1689238+allilevine@users.noreply.github.com>
Date: Wed Mar 11 10:44:47 2026 -0400
Email Editor: Add rich cards for WordPress embeds with performance cap (#63542)
* Add excerpt and site icon to email embed rich cards
Fetch the WordPress embed page ({url}/embed/) to extract the post
excerpt and site icon for rich embed cards. The excerpt is displayed
below the title with a "Continue reading" link, and the provider
row now shows the site icon alongside the provider name using an
email-safe inline table layout.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix PHPStan errors for email-editor CI
- Use is_string() checks instead of (string) cast on cached transient
values to satisfy PHPStan level 9 mixed-type rules
- Accept string|false in mock_oembed_for_example_com() to handle
wp_json_encode() return type
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Cap WordPress embed rich cards at 5 per email with compact link fallback
Performance improvement: limits rich card embeds (which require HTTP
fetches) to 5 per email render. Excess embeds render as styled compact
link cards with no HTTP request. Failed fetches also use the compact
card fallback. Only WordPress embeds (type "wp-embed") attempt the
embed page fetch — non-WP URLs skip the fetch entirely.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Update changelog entry to reflect full scope of changes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix embed renderer state persisting across emails and stale transient handling
The Embed renderer instance was cached in the singleton Initializer,
causing the rich card counter to spill between emails. Fix by firing a
woocommerce_email_editor_render_start action at the start of each email
render and clearing the renderer cache on that hook.
Also fix stale transient detection: old-format cached arrays (with only
excerpt + site_icon_url keys) were incorrectly treated as negative cache
entries after the format was expanded to 6 keys. Now only empty string
transients are treated as negative cache.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Harden embed renderer: try/finally for reset, fix embed URL with query strings, restore libxml state
- Wrap render_without_css_inline() in try/finally so reset() always runs
even if block rendering throws
- Strip query strings and fragments when building the /embed/ URL so
URLs like ?ref=email don't break the embed endpoint path
- Save and restore libxml_use_internal_errors state around DOMDocument
parsing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Rename rich_card_count to embed_fetch_count to clarify intent
The counter caps HTTP fetch attempts (not successful renders), so
the naming now reflects that the limit exists to bound outbound requests.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Use theme text color for excerpt and provider in embed rich cards
Replace hardcoded #555 and #757575 with the theme's text color so
embed cards respect dark-themed email templates.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Limit embed page response size to 150 KB
Cap wp_safe_remote_get response to match WordPress core's oEmbed
discovery limit, protecting against oversized responses.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
diff --git a/packages/php/email-editor/changelog/add-email-embed-rich-card-excerpt b/packages/php/email-editor/changelog/add-email-embed-rich-card-excerpt
new file mode 100644
index 00000000000..f357872ccff
--- /dev/null
+++ b/packages/php/email-editor/changelog/add-email-embed-rich-card-excerpt
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Add rich cards for WordPress embeds in emails with excerpt, site icon, and a performance cap of 5 per email.
diff --git a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
index 29dfcde64c1..1c3b0f5dd3f 100644
--- a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
+++ b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
@@ -173,8 +173,12 @@ class Content_Renderer {
public function render_without_css_inline( WP_Post $post, WP_Block_Template $template ): array {
$this->set_template_globals( $post, $template );
$this->initialize();
- $rendered_html = get_the_block_template_html();
- $this->reset();
+ try {
+ do_action( 'woocommerce_email_editor_render_start' );
+ $rendered_html = get_the_block_template_html();
+ } finally {
+ $this->reset();
+ }
return array(
'html' => $rendered_html,
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-embed.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-embed.php
index 7715cce1029..14870625bcc 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-embed.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-embed.php
@@ -13,6 +13,7 @@ use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Audio;
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Video;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Html_Processing_Helper;
+use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
/**
* Embed block renderer.
@@ -22,6 +23,20 @@ use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Html_Processing_Helper
* Video providers: YouTube - rendered as video thumbnails with play buttons.
*/
class Embed extends Abstract_Block_Renderer {
+ /**
+ * Maximum number of embed page fetch attempts per email render.
+ * Beyond this limit, embeds render as compact link cards (no HTTP fetch).
+ * Counts attempts, not successes, to cap outbound HTTP requests.
+ */
+ private const MAX_EMBED_FETCHES = 5;
+
+ /**
+ * Number of embed page fetch attempts so far by this instance.
+ *
+ * @var int
+ */
+ private int $embed_fetch_count = 0;
+
/**
* Supported audio providers with their configuration.
*
@@ -148,7 +163,23 @@ class Embed extends Abstract_Block_Renderer {
// Check if this is a supported audio or video provider embed and has a valid URL.
$provider = $this->get_supported_provider( $attr, $block_content );
if ( empty( $provider ) ) {
- // For non-supported embeds, try to render as a simple link fallback.
+ // For non-supported embeds, try to render as a rich card using oEmbed data.
+ // Only attempt the embed page fetch for WordPress embeds (type "wp-embed")
+ // to avoid wasted HTTP requests to non-WordPress sites.
+ $url = $this->extract_provider_url( $attr, $block_content );
+ $is_wp_embed = isset( $attr['type'] ) && 'wp-embed' === $attr['type'];
+ if ( ! empty( $url ) && $is_wp_embed ) {
+ if ( $this->embed_fetch_count >= self::MAX_EMBED_FETCHES ) {
+ return $this->render_compact_link_card( $url, $parsed_block, $rendering_context );
+ }
+ ++$this->embed_fetch_count;
+ $card_result = $this->render_link_embed_card( $url, $parsed_block, $rendering_context );
+ if ( ! empty( $card_result ) ) {
+ return $card_result;
+ }
+ // Fetch failed — render as compact link card instead of plain link.
+ return $this->render_compact_link_card( $url, $parsed_block, $rendering_context );
+ }
return $this->render_link_fallback( $attr, $block_content, $parsed_block, $rendering_context );
}
@@ -639,4 +670,341 @@ class Embed extends Abstract_Block_Renderer {
set_transient( $cache_key, '', $cache_ttl );
return '';
}
+
+ /**
+ * Fetch metadata from a WordPress embed page.
+ *
+ * WordPress sites expose a {url}/embed/ endpoint that renders a post preview
+ * containing the title, excerpt, featured image, provider name, and site icon.
+ * This single fetch provides all the data needed for the rich embed card.
+ *
+ * @param string $url URL of the post to fetch metadata for.
+ * @return array{ title: string, thumbnail_url: string, provider_name: string, provider_url: string, excerpt: string, site_icon_url: string } Embed page metadata.
+ */
+ private function fetch_embed_page_data( string $url ): array {
+ $empty_result = array(
+ 'title' => '',
+ 'thumbnail_url' => '',
+ 'provider_name' => '',
+ 'provider_url' => '',
+ 'excerpt' => '',
+ 'site_icon_url' => '',
+ );
+
+ $parsed = wp_parse_url( $url );
+ if ( empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) {
+ return $empty_result;
+ }
+ $embed_url = $parsed['scheme'] . '://' . $parsed['host']
+ . ( isset( $parsed['port'] ) ? ':' . $parsed['port'] : '' )
+ . trailingslashit( $parsed['path'] ?? '/' ) . 'embed/';
+
+ if ( ! $this->is_valid_url( $embed_url ) ) {
+ return $empty_result;
+ }
+
+ $cache_key = 'wc_email_embed_pg_' . md5( $url );
+ $cached = get_transient( $cache_key );
+
+ if ( false !== $cached && is_array( $cached )
+ && isset( $cached['title'], $cached['thumbnail_url'], $cached['provider_name'], $cached['provider_url'], $cached['excerpt'], $cached['site_icon_url'] )
+ && is_string( $cached['title'] ) && is_string( $cached['thumbnail_url'] )
+ && is_string( $cached['provider_name'] ) && is_string( $cached['provider_url'] )
+ && is_string( $cached['excerpt'] ) && is_string( $cached['site_icon_url'] )
+ ) {
+ return array(
+ 'title' => $cached['title'],
+ 'thumbnail_url' => $cached['thumbnail_url'],
+ 'provider_name' => $cached['provider_name'],
+ 'provider_url' => $cached['provider_url'],
+ 'excerpt' => $cached['excerpt'],
+ 'site_icon_url' => $cached['site_icon_url'],
+ );
+ }
+ if ( is_string( $cached ) ) {
+ // Negative cache (empty string from previous failure).
+ return $empty_result;
+ }
+
+ /** This filter is documented in packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-embed.php */
+ $cache_ttl = (int) apply_filters( 'oembed_ttl', DAY_IN_SECONDS, $url, array(), '' );
+
+ $response = wp_safe_remote_get(
+ $embed_url,
+ array(
+ 'timeout' => 5,
+ 'limit_response_size' => 150 * KB_IN_BYTES,
+ )
+ );
+
+ if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
+ set_transient( $cache_key, '', $cache_ttl );
+ return $empty_result;
+ }
+
+ $body = wp_remote_retrieve_body( $response );
+ if ( empty( $body ) ) {
+ set_transient( $cache_key, '', $cache_ttl );
+ return $empty_result;
+ }
+
+ // Parse HTML and extract metadata using XPath.
+ $previous_libxml_errors = libxml_use_internal_errors( true );
+ try {
+ $dom = new \DOMDocument();
+ $dom->loadHTML( '<?xml encoding="UTF-8">' . $body, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
+ libxml_clear_errors();
+ } finally {
+ libxml_use_internal_errors( $previous_libxml_errors );
+ }
+
+ $xpath = new \DOMXPath( $dom );
+
+ // Extract title from wp-embed-heading.
+ $title = '';
+ $heading_nodes = $xpath->query( "//*[contains(concat(' ', normalize-space(@class), ' '), ' wp-embed-heading ')]" );
+ $heading_node = ( false !== $heading_nodes && $heading_nodes->length > 0 ) ? $heading_nodes->item( 0 ) : null;
+ if ( $heading_node instanceof \DOMElement ) {
+ $title = trim( $heading_node->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+ }
+
+ // Extract thumbnail from featured image.
+ $thumbnail_url = '';
+ $featured_nodes = $xpath->query( "//*[contains(concat(' ', normalize-space(@class), ' '), ' wp-embed-featured-image ')]//img" );
+ $featured_node = ( false !== $featured_nodes && $featured_nodes->length > 0 ) ? $featured_nodes->item( 0 ) : null;
+ if ( $featured_node instanceof \DOMElement ) {
+ $img_src = $featured_node->getAttribute( 'src' );
+ if ( $this->is_valid_url( $img_src ) ) {
+ $thumbnail_url = $img_src;
+ }
+ }
+
+ // Extract provider name from site title.
+ $provider_name = '';
+ $site_title_nodes = $xpath->query( "//*[contains(concat(' ', normalize-space(@class), ' '), ' wp-embed-site-title ')]//span" );
+ $site_title_node = ( false !== $site_title_nodes && $site_title_nodes->length > 0 ) ? $site_title_nodes->item( 0 ) : null;
+ if ( $site_title_node instanceof \DOMElement ) {
+ $provider_name = trim( $site_title_node->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+ }
+
+ // Extract provider URL from site title link.
+ $provider_url = '';
+ $site_link_nodes = $xpath->query( "//*[contains(concat(' ', normalize-space(@class), ' '), ' wp-embed-site-title ')]//a" );
+ $site_link_node = ( false !== $site_link_nodes && $site_link_nodes->length > 0 ) ? $site_link_nodes->item( 0 ) : null;
+ if ( $site_link_node instanceof \DOMElement ) {
+ $href = $site_link_node->getAttribute( 'href' );
+ if ( $this->is_valid_url( $href ) ) {
+ $provider_url = $href;
+ }
+ }
+
+ // Extract excerpt.
+ $excerpt = '';
+ $excerpt_nodes = $xpath->query( "//*[contains(concat(' ', normalize-space(@class), ' '), ' wp-embed-excerpt ')]" );
+ $excerpt_node = ( false !== $excerpt_nodes && $excerpt_nodes->length > 0 ) ? $excerpt_nodes->item( 0 ) : null;
+ if ( $excerpt_node instanceof \DOMElement ) {
+ $excerpt = trim( $excerpt_node->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+ }
+
+ // Cap at 200 characters as a safety limit.
+ if ( mb_strlen( $excerpt ) > 200 ) {
+ $excerpt = mb_substr( $excerpt, 0, 200 );
+ $excerpt = rtrim( $excerpt ) . '…';
+ }
+
+ // Extract site icon URL.
+ $site_icon_url = '';
+ $icon_nodes = $xpath->query( "//img[contains(concat(' ', normalize-space(@class), ' '), ' wp-embed-site-icon ')]" );
+ $icon_node = ( false !== $icon_nodes && $icon_nodes->length > 0 ) ? $icon_nodes->item( 0 ) : null;
+ if ( $icon_node instanceof \DOMElement ) {
+ $icon_src = $icon_node->getAttribute( 'src' );
+ if ( $this->is_valid_url( $icon_src ) ) {
+ $site_icon_url = $icon_src;
+ }
+ }
+
+ $result = array(
+ 'title' => $title,
+ 'thumbnail_url' => $thumbnail_url,
+ 'provider_name' => $provider_name,
+ 'provider_url' => $provider_url,
+ 'excerpt' => $excerpt,
+ 'site_icon_url' => $site_icon_url,
+ );
+
+ set_transient( $cache_key, $result, $cache_ttl );
+ return $result;
+ }
+
+ /**
+ * Render a link embed as a rich card using data from the WordPress embed page.
+ *
+ * @param string $url URL to render as a card.
+ * @param array $parsed_block Parsed block.
+ * @param Rendering_Context $rendering_context Rendering context.
+ * @return string Rendered card HTML or empty string if embed page data is insufficient.
+ */
+ private function render_link_embed_card( string $url, array $parsed_block, Rendering_Context $rendering_context ): string {
+ $embed_data = $this->fetch_embed_page_data( $url );
+
+ if ( empty( $embed_data['title'] ) ) {
+ return '';
+ }
+
+ $title = $embed_data['title'];
+ $thumbnail_url = $embed_data['thumbnail_url'];
+ $provider_name = ! empty( $embed_data['provider_name'] )
+ ? $embed_data['provider_name']
+ : (string) wp_parse_url( $url, PHP_URL_HOST );
+ $provider_url = $embed_data['provider_url'];
+ $excerpt = $embed_data['excerpt'];
+ $site_icon_url = $embed_data['site_icon_url'];
+
+ $email_styles = $rendering_context->get_theme_styles();
+ $text_color = $email_styles['color']['text'] ?? '#1e1e1e';
+ $text_color = Html_Processing_Helper::sanitize_color( $text_color );
+ $link_color = $email_styles['elements']['link']['color']['text'] ?? '#0073aa';
+ $link_color = Html_Processing_Helper::sanitize_color( $link_color );
+
+ // Build card rows.
+ $rows_html = '';
+
+ // Optional thumbnail row.
+ if ( ! empty( $thumbnail_url ) ) {
+ $thumbnail_cell = Table_Wrapper_Helper::render_table_cell(
+ sprintf(
+ '<a href="%s" target="_blank" rel="noopener nofollow"><img src="%s" alt="%s" style="display: block; width: 100%%; border-radius: 4px 4px 0 0;" /></a>',
+ esc_url( $url ),
+ esc_url( $thumbnail_url ),
+ esc_attr( $title )
+ ),
+ array( 'style' => 'padding: 0;' )
+ );
+ $rows_html .= '<tr>' . $thumbnail_cell . '</tr>';
+ }
+
+ // Build content: title, optional excerpt, provider.
+ $content_parts = sprintf(
+ '<a href="%s" target="_blank" rel="noopener nofollow" style="color: %s; text-decoration: none; font-weight: bold;">%s</a>',
+ esc_url( $url ),
+ esc_attr( $text_color ),
+ esc_html( $title )
+ );
+
+ if ( ! empty( $excerpt ) ) {
+ $content_parts .= sprintf(
+ '<br /><span style="font-size: 14px; color: %s; line-height: 1.4;">%s</span>',
+ esc_attr( $text_color ),
+ esc_html( $excerpt )
+ );
+ $content_parts .= sprintf(
+ ' <a href="%s" target="_blank" rel="noopener nofollow" style="font-size: 14px; color: %s; text-decoration: underline;">%s</a>',
+ esc_url( $url ),
+ esc_attr( $link_color ),
+ esc_html__( 'Continue reading', 'woocommerce' )
+ );
+ }
+
+ // Provider row with optional site icon.
+ $provider_text = ! empty( $provider_url )
+ ? sprintf(
+ '<a href="%s" target="_blank" rel="noopener nofollow" style="font-size: 13px; color: %s; text-decoration: none;">%s</a>',
+ esc_url( $provider_url ),
+ esc_attr( $text_color ),
+ esc_html( $provider_name )
+ )
+ : sprintf(
+ '<span style="font-size: 13px; color: %s;">%s</span>',
+ esc_attr( $text_color ),
+ esc_html( $provider_name )
+ );
+
+ if ( ! empty( $site_icon_url ) ) {
+ $content_parts .= sprintf(
+ '<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin-top: 16px;">'
+ . '<tr>'
+ . '<td style="vertical-align: middle; padding-right: 6px;">'
+ . '<img src="%s" width="16" height="16" alt="" style="display: block; border-radius: 2px;" />'
+ . '</td>'
+ . '<td style="vertical-align: middle;">%s</td>'
+ . '</tr></table>',
+ esc_url( $site_icon_url ),
+ $provider_text
+ );
+ } else {
+ $content_parts .= '<br />' . $provider_text;
+ }
+
+ $content_cell = Table_Wrapper_Helper::render_table_cell(
+ $content_parts,
+ array( 'style' => 'padding: 12px;' )
+ );
+ $rows_html .= '<tr>' . $content_cell . '</tr>';
+
+ $card_html = sprintf(
+ '<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border: 1px solid #ddd; border-radius: 4px; width: 100%%;">'
+ );
+ $card_html .= '<tbody>' . $rows_html . '</tbody></table>';
+
+ $outlook_wrapped = Table_Wrapper_Helper::render_outlook_table_wrapper(
+ $card_html,
+ array(
+ 'align' => 'left',
+ 'width' => '100%',
+ )
+ );
+
+ return $this->add_spacer(
+ $outlook_wrapped,
+ $parsed_block['email_attrs'] ?? array()
+ );
+ }
+
+ /**
+ * Render a compact link card for embeds that exceed the rich card cap.
+ * Displays the URL in a bordered card without making any HTTP requests.
+ *
+ * @param string $url URL to render.
+ * @param array $parsed_block Parsed block.
+ * @param Rendering_Context $rendering_context Rendering context.
+ * @return string Rendered compact link card HTML.
+ */
+ private function render_compact_link_card( string $url, array $parsed_block, Rendering_Context $rendering_context ): string {
+ $email_styles = $rendering_context->get_theme_styles();
+ $link_color = $email_styles['elements']['link']['color']['text'] ?? '#0073aa';
+ $link_color = Html_Processing_Helper::sanitize_color( $link_color );
+
+ // Build display text: strip scheme from URL.
+ $display_host = (string) wp_parse_url( $url, PHP_URL_HOST );
+ $display_path = (string) wp_parse_url( $url, PHP_URL_PATH );
+ $display_text = $display_host . $display_path;
+
+ $link_html = sprintf(
+ '<a href="%s" target="_blank" rel="noopener nofollow" style="color: %s; text-decoration: none;">%s</a>',
+ esc_url( $url ),
+ esc_attr( $link_color ),
+ esc_html( $display_text )
+ );
+
+ $content_cell = Table_Wrapper_Helper::render_table_cell(
+ $link_html,
+ array( 'style' => 'padding: 12px;' )
+ );
+
+ $card_html = '<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border: 1px solid #ddd; border-radius: 4px; width: 100%;">';
+ $card_html .= '<tbody><tr>' . $content_cell . '</tr></tbody></table>';
+
+ $outlook_wrapped = Table_Wrapper_Helper::render_outlook_table_wrapper(
+ $card_html,
+ array(
+ 'align' => 'left',
+ 'width' => '100%',
+ )
+ );
+
+ return $this->add_spacer(
+ $outlook_wrapped,
+ $parsed_block['email_attrs'] ?? array()
+ );
+ }
}
diff --git a/packages/php/email-editor/src/Integrations/Core/class-initializer.php b/packages/php/email-editor/src/Integrations/Core/class-initializer.php
index e402e0f1de1..604d21ea686 100644
--- a/packages/php/email-editor/src/Integrations/Core/class-initializer.php
+++ b/packages/php/email-editor/src/Integrations/Core/class-initializer.php
@@ -91,6 +91,14 @@ class Initializer {
public function initialize(): void {
add_filter( 'woocommerce_email_editor_theme_json', array( $this, 'adjust_theme_json' ), 10, 1 );
add_filter( 'safe_style_css', array( $this, 'allow_styles' ) );
+ add_action( 'woocommerce_email_editor_render_start', array( $this, 'reset_renderers' ) );
+ }
+
+ /**
+ * Clear cached renderer instances so stateful renderers reset between emails.
+ */
+ public function reset_renderers(): void {
+ $this->renderers = array();
}
/**
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Embed_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Embed_Test.php
index a1fd96bb7c5..3cfd6c718c3 100644
--- a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Embed_Test.php
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Embed_Test.php
@@ -724,4 +724,478 @@ class Embed_Test extends \Email_Editor_Integration_Test_Case {
$this->assertNotEmpty( $rendered );
$this->assertStringContainsString( 'background-image', $rendered, 'VideoPress embed should have background image' );
}
+
+ /**
+ * Helper to mock the embed page HTTP response for example.com URLs.
+ *
+ * @param string $embed_page_html HTML for the embed page response.
+ * @return callable The HTTP filter callback (for removal in cleanup).
+ */
+ private function mock_embed_page_for_example_com( string $embed_page_html ): callable {
+ $filter_callback = function ( $preempt, $args, $url ) use ( $embed_page_html ) {
+ // Intercept embed page requests (URLs ending with /embed/).
+ if ( preg_match( '#example\.com/.*/embed/?$#', $url ) ) {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => $embed_page_html,
+ );
+ }
+ return $preempt;
+ };
+
+ add_filter( 'pre_http_request', $filter_callback, 10, 3 );
+ return $filter_callback;
+ }
+
+ /**
+ * Helper to clean up embed page mocks.
+ *
+ * @param callable $filter_callback The HTTP filter callback to remove.
+ * @param string $url The URL whose transient should be deleted.
+ */
+ private function cleanup_embed_mock( callable $filter_callback, string $url ): void {
+ remove_filter( 'pre_http_request', $filter_callback, 10 );
+ delete_transient( 'wc_email_embed_pg_' . md5( $url ) );
+ }
+
+ /**
+ * Test that wp-embed link renders as rich card from embed page data
+ */
+ public function test_renders_wp_embed_as_rich_card(): void {
+ $url = 'https://example.com/my-blog-post';
+ $embed_page_html = '<html><body><div class="wp-embed">'
+ . '<p class="wp-embed-heading"><a href="' . $url . '" target="_top">My Blog Post Title</a></p>'
+ . '<div class="wp-embed-excerpt"><p>A short excerpt about garlic roasted potatoes and other delicious things.</p></div>'
+ . '<div class="wp-embed-featured-image square"><a href="' . $url . '" target="_top"><img src="https://example.com/image.jpg" alt="" /></a></div>'
+ . '<div class="wp-embed-site-title"><a href="https://example.com" target="_top">'
+ . '<img src="https://example.com/icon-32.png" width="32" height="32" alt="" class="wp-embed-site-icon" />'
+ . '<span>Example Blog</span></a></div>'
+ . '</div></body></html>';
+ $filter_callback = $this->mock_embed_page_for_example_com( $embed_page_html );
+
+ $parsed_wp_embed = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'url' => $url,
+ 'type' => 'wp-embed',
+ 'providerNameSlug' => 'my-blog',
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-wp-embed is-provider-my-blog"><div class="wp-block-embed__wrapper">' . $url . '</div></figure>',
+ );
+
+ try {
+ $rendered = $this->embed_renderer->render( $parsed_wp_embed['innerHTML'], $parsed_wp_embed, $this->rendering_context );
+ } finally {
+ $this->cleanup_embed_mock( $filter_callback, $url );
+ }
+
+ $this->assertStringContainsString( 'My Blog Post Title', $rendered, 'Card should contain the title' );
+ $this->assertStringContainsString( 'https://example.com/image.jpg', $rendered, 'Card should contain the thumbnail image' );
+ $this->assertStringContainsString( 'garlic roasted potatoes', $rendered, 'Card should contain the excerpt from embed page' );
+ $this->assertStringContainsString( 'Continue reading', $rendered, 'Card should contain a Continue reading link' );
+ $this->assertStringContainsString( 'https://example.com/icon-32.png', $rendered, 'Card should contain the site icon' );
+ $this->assertStringContainsString( 'width="16" height="16"', $rendered, 'Site icon should be scaled to 16px' );
+ $this->assertStringContainsString( '<a href="https://example.com"', $rendered, 'Provider name should be linked' );
+ $this->assertStringContainsString( 'Example Blog', $rendered, 'Card should contain the provider name' );
+ $this->assertStringContainsString( '<table', $rendered, 'Card should use table-based layout' );
+ $this->assertStringContainsString( 'border: 1px solid #ddd', $rendered, 'Card should have a border' );
+ }
+
+ /**
+ * Test that card renders without thumbnail when embed page has no featured image
+ */
+ public function test_renders_card_without_thumbnail(): void {
+ $url = 'https://example.com/no-image-post';
+ $embed_page_html = '<html><body><div class="wp-embed">'
+ . '<p class="wp-embed-heading"><a href="' . $url . '" target="_top">A Post Without Image</a></p>'
+ . '<div class="wp-embed-site-title"><a href="https://example.com" target="_top">'
+ . '<span>No Image Blog</span></a></div>'
+ . '</div></body></html>';
+ $filter_callback = $this->mock_embed_page_for_example_com( $embed_page_html );
+
+ $parsed_wp_embed = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'url' => $url,
+ 'type' => 'wp-embed',
+ 'providerNameSlug' => 'no-image-blog',
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-wp-embed"><div class="wp-block-embed__wrapper">' . $url . '</div></figure>',
+ );
+
+ try {
+ $rendered = $this->embed_renderer->render( $parsed_wp_embed['innerHTML'], $parsed_wp_embed, $this->rendering_context );
+ } finally {
+ $this->cleanup_embed_mock( $filter_callback, $url );
+ }
+
+ $this->assertStringContainsString( 'A Post Without Image', $rendered, 'Card should contain the title' );
+ $this->assertStringContainsString( 'No Image Blog', $rendered, 'Card should contain the provider name' );
+ $this->assertStringNotContainsString( '<img', $rendered, 'Card should not contain an image tag' );
+ }
+
+ /**
+ * Test that card uses domain as provider name when embed page has no site title
+ */
+ public function test_renders_card_with_domain_fallback_for_provider(): void {
+ $url = 'https://example.com/domain-fallback';
+ $embed_page_html = '<html><body><div class="wp-embed">'
+ . '<p class="wp-embed-heading"><a href="' . $url . '" target="_top">Domain Fallback Post</a></p>'
+ . '</div></body></html>';
+ $filter_callback = $this->mock_embed_page_for_example_com( $embed_page_html );
+
+ $parsed_wp_embed = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'url' => $url,
+ 'type' => 'wp-embed',
+ 'providerNameSlug' => 'example-blog',
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-wp-embed"><div class="wp-block-embed__wrapper">' . $url . '</div></figure>',
+ );
+
+ try {
+ $rendered = $this->embed_renderer->render( $parsed_wp_embed['innerHTML'], $parsed_wp_embed, $this->rendering_context );
+ } finally {
+ $this->cleanup_embed_mock( $filter_callback, $url );
+ }
+
+ $this->assertStringContainsString( 'Domain Fallback Post', $rendered, 'Card should contain the title' );
+ $this->assertStringContainsString( 'example.com', $rendered, 'Card should fall back to domain as provider' );
+ }
+
+ /**
+ * Test that embed falls back to compact link card when embed page HTTP request fails
+ */
+ public function test_falls_back_to_compact_card_when_embed_page_fails(): void {
+ $url = 'https://example.com/failing-post';
+
+ // No mock registered — wp_safe_remote_get will fail in the test environment.
+ $parsed_wp_embed = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'url' => $url,
+ 'type' => 'wp-embed',
+ 'providerNameSlug' => 'failing-blog',
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-wp-embed"><div class="wp-block-embed__wrapper">' . $url . '</div></figure>',
+ );
+
+ try {
+ $rendered = $this->embed_renderer->render( $parsed_wp_embed['innerHTML'], $parsed_wp_embed, $this->rendering_context );
+ } finally {
+ delete_transient( 'wc_email_embed_pg_' . md5( $url ) );
+ }
+
+ $this->assertStringContainsString( '<a href="https://example.com/failing-post"', $rendered, 'Should contain link to URL' );
+ $this->assertStringContainsString( 'border: 1px solid #ddd', $rendered, 'Should render as compact link card' );
+ $this->assertStringContainsString( 'example.com/failing-post', $rendered, 'Should display URL without scheme' );
+ }
+
+ /**
+ * Test that embed falls back to compact link card when embed page has no title
+ */
+ public function test_falls_back_to_compact_card_when_embed_page_has_no_title(): void {
+ $url = 'https://example.com/no-title';
+ $embed_page_html = '<html><body><div class="wp-embed">'
+ . '<div class="wp-embed-site-title"><a href="https://example.com" target="_top">'
+ . '<span>Example</span></a></div>'
+ . '</div></body></html>';
+ $filter_callback = $this->mock_embed_page_for_example_com( $embed_page_html );
+
+ $parsed_wp_embed = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'url' => $url,
+ 'type' => 'wp-embed',
+ 'providerNameSlug' => 'no-title-blog',
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-wp-embed"><div class="wp-block-embed__wrapper">' . $url . '</div></figure>',
+ );
+
+ try {
+ $rendered = $this->embed_renderer->render( $parsed_wp_embed['innerHTML'], $parsed_wp_embed, $this->rendering_context );
+ } finally {
+ $this->cleanup_embed_mock( $filter_callback, $url );
+ }
+
+ $this->assertStringContainsString( '<a href="https://example.com/no-title"', $rendered, 'Should contain link to URL' );
+ $this->assertStringContainsString( 'border: 1px solid #ddd', $rendered, 'Should render as compact link card' );
+ $this->assertStringContainsString( 'example.com/no-title', $rendered, 'Should display URL without scheme' );
+ }
+
+ /**
+ * Test that card renders without thumbnail when featured image URL is invalid
+ */
+ public function test_renders_card_without_thumbnail_when_thumbnail_url_invalid(): void {
+ $url = 'https://example.com/bad-thumbnail';
+ $embed_page_html = '<html><body><div class="wp-embed">'
+ . '<p class="wp-embed-heading"><a href="' . $url . '" target="_top">Post With Bad Thumbnail</a></p>'
+ . '<div class="wp-embed-featured-image square"><a href="' . $url . '" target="_top"><img src="javascript:alert(1)" alt="" /></a></div>'
+ . '<div class="wp-embed-site-title"><a href="https://example.com" target="_top">'
+ . '<span>Sketchy Blog</span></a></div>'
+ . '</div></body></html>';
+ $filter_callback = $this->mock_embed_page_for_example_com( $embed_page_html );
+
+ $parsed_wp_embed = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'url' => $url,
+ 'type' => 'wp-embed',
+ 'providerNameSlug' => 'sketchy-blog',
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-wp-embed"><div class="wp-block-embed__wrapper">' . $url . '</div></figure>',
+ );
+
+ try {
+ $rendered = $this->embed_renderer->render( $parsed_wp_embed['innerHTML'], $parsed_wp_embed, $this->rendering_context );
+ } finally {
+ $this->cleanup_embed_mock( $filter_callback, $url );
+ }
+
+ $this->assertStringContainsString( 'Post With Bad Thumbnail', $rendered, 'Card should still render with title' );
+ $this->assertStringNotContainsString( '<img', $rendered, 'Card should not contain an image tag' );
+ $this->assertStringNotContainsString( 'javascript:', $rendered, 'Card should not contain javascript URL' );
+ }
+
+ /**
+ * Test that embed page response is cached and reused on subsequent renders
+ */
+ public function test_embed_page_response_is_cached(): void {
+ $url = 'https://example.com/cached-post';
+ $embed_page_html = '<html><body><div class="wp-embed">'
+ . '<p class="wp-embed-heading"><a href="' . $url . '" target="_top">Cached Post</a></p>'
+ . '<div class="wp-embed-site-title"><a href="https://example.com" target="_top">'
+ . '<span>Cache Blog</span></a></div>'
+ . '</div></body></html>';
+ $filter_callback = $this->mock_embed_page_for_example_com( $embed_page_html );
+
+ $parsed_wp_embed = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'url' => $url,
+ 'type' => 'wp-embed',
+ 'providerNameSlug' => 'cache-blog',
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-wp-embed"><div class="wp-block-embed__wrapper">' . $url . '</div></figure>',
+ );
+
+ try {
+ $first_render = $this->embed_renderer->render( $parsed_wp_embed['innerHTML'], $parsed_wp_embed, $this->rendering_context );
+
+ remove_filter( 'pre_http_request', $filter_callback, 10 );
+
+ $second_render = $this->embed_renderer->render( $parsed_wp_embed['innerHTML'], $parsed_wp_embed, $this->rendering_context );
+ } finally {
+ $this->cleanup_embed_mock( $filter_callback, $url );
+ }
+
+ $this->assertStringContainsString( 'Cached Post', $first_render, 'First render should contain the title' );
+ $this->assertStringContainsString( 'Cached Post', $second_render, 'Second render should also contain the title from cache' );
+ }
+
+ /**
+ * Test that link embed card respects email_attrs spacing
+ */
+ public function test_link_embed_card_respects_email_attrs_spacing(): void {
+ $url = 'https://example.com/spaced-post';
+ $embed_page_html = '<html><body><div class="wp-embed">'
+ . '<p class="wp-embed-heading"><a href="' . $url . '" target="_top">Spaced Post</a></p>'
+ . '<div class="wp-embed-site-title"><a href="https://example.com" target="_top">'
+ . '<span>Space Blog</span></a></div>'
+ . '</div></body></html>';
+ $filter_callback = $this->mock_embed_page_for_example_com( $embed_page_html );
+
+ $parsed_wp_embed = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'url' => $url,
+ 'type' => 'wp-embed',
+ 'providerNameSlug' => 'space-blog',
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-wp-embed"><div class="wp-block-embed__wrapper">' . $url . '</div></figure>',
+ 'email_attrs' => array(
+ 'margin-top' => '30px',
+ ),
+ );
+
+ try {
+ $rendered = $this->embed_renderer->render( $parsed_wp_embed['innerHTML'], $parsed_wp_embed, $this->rendering_context );
+ } finally {
+ $this->cleanup_embed_mock( $filter_callback, $url );
+ }
+
+ $this->assertStringContainsString( 'Spaced Post', $rendered, 'Card should render' );
+ $this->assertStringContainsString( 'margin-top:30px', $rendered, 'Card should respect email_attrs spacing' );
+ }
+
+ /**
+ * Test that card shows thumbnail when embed page has a featured image
+ */
+ public function test_renders_card_with_thumbnail(): void {
+ $url = 'https://example.com/thumb-post';
+ $embed_page_html = '<html><body><div class="wp-embed">'
+ . '<p class="wp-embed-heading"><a href="' . $url . '" target="_top">Post With Thumbnail</a></p>'
+ . '<div class="wp-embed-featured-image square"><a href="' . $url . '" target="_top"><img src="https://example.com/featured.jpg" alt="" /></a></div>'
+ . '<div class="wp-embed-site-title"><a href="https://example.com" target="_top">'
+ . '<span>Image Blog</span></a></div>'
+ . '</div></body></html>';
+ $filter_callback = $this->mock_embed_page_for_example_com( $embed_page_html );
+
+ $parsed_wp_embed = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'url' => $url,
+ 'type' => 'wp-embed',
+ 'providerNameSlug' => 'image-blog',
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-wp-embed"><div class="wp-block-embed__wrapper">' . $url . '</div></figure>',
+ );
+
+ try {
+ $rendered = $this->embed_renderer->render( $parsed_wp_embed['innerHTML'], $parsed_wp_embed, $this->rendering_context );
+ } finally {
+ $this->cleanup_embed_mock( $filter_callback, $url );
+ }
+
+ $this->assertStringContainsString( 'Post With Thumbnail', $rendered, 'Card should render with title' );
+ $this->assertStringContainsString( 'https://example.com/featured.jpg', $rendered, 'Card should show featured image as thumbnail' );
+ $this->assertStringContainsString( 'Image Blog', $rendered, 'Card should contain provider name' );
+ }
+
+ /**
+ * Test that card renders without excerpt when embed page has no wp-embed-excerpt element
+ */
+ public function test_renders_card_without_excerpt_when_no_excerpt_element(): void {
+ $url = 'https://example.com/no-excerpt-element';
+ $embed_page_html = '<html><body><div class="wp-embed">'
+ . '<p class="wp-embed-heading"><a href="' . $url . '">Post Title</a></p>'
+ . '<div class="wp-embed-site-title"><a href="https://example.com" target="_top">'
+ . '<span>Example Blog</span></a></div>'
+ . '</div></body></html>';
+ $filter_callback = $this->mock_embed_page_for_example_com( $embed_page_html );
+
+ $parsed_wp_embed = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'url' => $url,
+ 'type' => 'wp-embed',
+ 'providerNameSlug' => 'example-blog',
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-wp-embed"><div class="wp-block-embed__wrapper">' . $url . '</div></figure>',
+ );
+
+ try {
+ $rendered = $this->embed_renderer->render( $parsed_wp_embed['innerHTML'], $parsed_wp_embed, $this->rendering_context );
+ } finally {
+ $this->cleanup_embed_mock( $filter_callback, $url );
+ }
+
+ $this->assertStringContainsString( 'Post Title', $rendered, 'Card should still render title' );
+ $this->assertStringContainsString( 'Example Blog', $rendered, 'Card should contain provider name' );
+ $this->assertStringNotContainsString( 'line-height: 1.4;', $rendered, 'Card should not contain excerpt styling' );
+ }
+
+ /**
+ * Test that rich cards are capped at five per render instance
+ */
+ public function test_caps_rich_cards_at_five_per_render(): void {
+ $embed_page_html = '<html><body><div class="wp-embed">'
+ . '<p class="wp-embed-heading"><a href="https://example.com/post" target="_top">Rich Card Title</a></p>'
+ . '<div class="wp-embed-site-title"><a href="https://example.com" target="_top">'
+ . '<span>Example Blog</span></a></div>'
+ . '</div></body></html>';
+ $filter_callback = $this->mock_embed_page_for_example_com( $embed_page_html );
+
+ $urls = array();
+ try {
+ for ( $i = 1; $i <= 6; $i++ ) {
+ $url = 'https://example.com/post-' . $i;
+ $urls[] = $url;
+
+ $parsed_block = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'url' => $url,
+ 'type' => 'wp-embed',
+ 'providerNameSlug' => 'example-blog',
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-wp-embed"><div class="wp-block-embed__wrapper">' . $url . '</div></figure>',
+ );
+
+ $rendered = $this->embed_renderer->render( $parsed_block['innerHTML'], $parsed_block, $this->rendering_context );
+
+ if ( $i <= 5 ) {
+ $this->assertStringContainsString( 'Rich Card Title', $rendered, "Embed #{$i} should render as a rich card" );
+ } else {
+ $this->assertStringNotContainsString( 'Rich Card Title', $rendered, 'Embed #6 should NOT render as a rich card' );
+ $this->assertStringContainsString( 'border: 1px solid #ddd', $rendered, 'Embed #6 should render as a compact link card' );
+ $this->assertStringContainsString( 'example.com/post-6', $rendered, 'Compact card should display the URL' );
+ }
+ }
+ } finally {
+ remove_filter( 'pre_http_request', $filter_callback, 10 );
+ foreach ( $urls as $url ) {
+ delete_transient( 'wc_email_embed_pg_' . md5( $url ) );
+ }
+ }
+ }
+
+ /**
+ * Test that compact link card shows URL in a styled card with theme link color
+ */
+ public function test_compact_link_card_shows_url_in_card(): void {
+ $embed_page_html = '<html><body><div class="wp-embed">'
+ . '<p class="wp-embed-heading"><a href="https://example.com/post" target="_top">Title</a></p>'
+ . '<div class="wp-embed-site-title"><a href="https://example.com" target="_top">'
+ . '<span>Blog</span></a></div>'
+ . '</div></body></html>';
+ $filter_callback = $this->mock_embed_page_for_example_com( $embed_page_html );
+
+ $urls = array();
+ try {
+ // Exhaust the 5 rich card slots.
+ for ( $i = 1; $i <= 5; $i++ ) {
+ $url = 'https://example.com/filler-' . $i;
+ $urls[] = $url;
+
+ $parsed_block = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'url' => $url,
+ 'type' => 'wp-embed',
+ 'providerNameSlug' => 'example-blog',
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-wp-embed"><div class="wp-block-embed__wrapper">' . $url . '</div></figure>',
+ );
+ $this->embed_renderer->render( $parsed_block['innerHTML'], $parsed_block, $this->rendering_context );
+ }
+
+ // The 6th should be a compact link card.
+ $target_url = 'https://example.com/the-target-post';
+ $urls[] = $target_url;
+ $parsed_block = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'url' => $target_url,
+ 'type' => 'wp-embed',
+ 'providerNameSlug' => 'example-blog',
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-wp-embed"><div class="wp-block-embed__wrapper">' . $target_url . '</div></figure>',
+ );
+ $rendered = $this->embed_renderer->render( $parsed_block['innerHTML'], $parsed_block, $this->rendering_context );
+
+ $this->assertStringContainsString( 'border: 1px solid #ddd', $rendered, 'Compact card should have a card border' );
+ $this->assertStringContainsString( 'border-radius: 4px', $rendered, 'Compact card should have rounded corners' );
+ $this->assertStringContainsString( 'example.com/the-target-post', $rendered, 'Compact card should display the URL without scheme' );
+ $this->assertStringContainsString( 'href="https://example.com/the-target-post"', $rendered, 'Compact card URL should link to the original URL' );
+ // Verify the link uses theme link color (default for test context).
+ $this->assertStringContainsString( 'text-decoration: none', $rendered, 'Compact card link should have no underline' );
+ } finally {
+ remove_filter( 'pre_http_request', $filter_callback, 10 );
+ foreach ( $urls as $url ) {
+ delete_transient( 'wc_email_embed_pg_' . md5( $url ) );
+ }
+ }
+ }
}