Commit ca3e2817254 for woocommerce
commit ca3e2817254d71e4a5c553d5c185191ebb2b68c1
Author: Tony Arcangelini <33258733+arcangelini@users.noreply.github.com>
Date: Sat Jun 13 18:32:36 2026 +0200
Email Editor: Add Vimeo, TikTok, and Dailymotion support to the embed renderer (#65502)
* Add Vimeo support to email embed renderer
* Add changelog entry for Vimeo email embeds
* Add TikTok and Dailymotion support to email embed renderer
* Update changelog entry to cover all new video providers
* Refactor provider label switch to lookup map
* Size video embed thumbnails using oEmbed aspect ratio
* Consolidate video provider embed tests
* Bump video thumbnail cache key to avoid stale cache shape
* Back out aspect-ratio thumbnail sizing
* Restore label switch and legacy thumbnail cache key
* Consolidate video embed tests
* Count oEmbed thumbnail fetches toward embed fetch cap
* Check fetch cap after embed page cache lookup
diff --git a/packages/php/email-editor/changelog/add-email-rendering-for-more-video-embeds b/packages/php/email-editor/changelog/add-email-rendering-for-more-video-embeds
new file mode 100644
index 00000000000..a0bbe018e29
--- /dev/null
+++ b/packages/php/email-editor/changelog/add-email-rendering-for-more-video-embeds
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add support for Vimeo, TikTok, and Dailymotion embeds in the Email Editor package.
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 4c8ee64ffb7..bd5500d3a01 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
@@ -20,23 +20,39 @@ use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
* This renderer handles core/embed blocks, detecting audio and video provider embeds and rendering them appropriately.
*
* Audio providers: Spotify, SoundCloud, Pocket Casts, Mixcloud, ReverbNation - rendered as audio players.
- * Video providers: YouTube - rendered as video thumbnails with play buttons.
+ * Video providers: YouTube, VideoPress, Vimeo, TikTok, Dailymotion - 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).
+ * Maximum number of embed HTTP fetch attempts per email render.
+ * Covers both embed page fetches (rich cards) and oEmbed thumbnail lookups.
+ * Beyond this limit, embeds render without fetching (compact link cards or link fallbacks).
* Counts attempts, not successes, to cap outbound HTTP requests.
+ * Cached results render normally and do not count toward the limit.
*/
private const MAX_EMBED_FETCHES = 5;
/**
- * Number of embed page fetch attempts so far by this instance.
+ * Number of embed HTTP fetch attempts so far by this instance.
*
* @var int
*/
private int $embed_fetch_count = 0;
+ /**
+ * Check whether an embed HTTP fetch may be performed and count the attempt.
+ * Enforces the MAX_EMBED_FETCHES cap shared by embed page fetches and oEmbed thumbnail lookups.
+ *
+ * @return bool True if the fetch is within the limit and may proceed.
+ */
+ private function may_attempt_embed_fetch(): bool {
+ if ( $this->embed_fetch_count >= self::MAX_EMBED_FETCHES ) {
+ return false;
+ }
+ ++$this->embed_fetch_count;
+ return true;
+ }
+
/**
* Supported audio providers with their configuration.
*
@@ -71,14 +87,26 @@ class Embed extends Abstract_Block_Renderer {
* @var array
*/
private const VIDEO_PROVIDERS = array(
- 'youtube' => array(
+ 'youtube' => array(
'domains' => array( 'youtube.com', 'youtu.be' ),
'base_url' => 'https://www.youtube.com/',
),
- 'videopress' => array(
+ 'videopress' => array(
'domains' => array( 'videopress.com', 'video.wordpress.com' ),
'base_url' => 'https://videopress.com/',
),
+ 'vimeo' => array(
+ 'domains' => array( 'vimeo.com', 'player.vimeo.com' ),
+ 'base_url' => 'https://vimeo.com/',
+ ),
+ 'tiktok' => array(
+ 'domains' => array( 'tiktok.com' ),
+ 'base_url' => 'https://www.tiktok.com/',
+ ),
+ 'dailymotion' => array(
+ 'domains' => array( 'dailymotion.com', 'dai.ly' ),
+ 'base_url' => 'https://www.dailymotion.com/',
+ ),
);
/**
@@ -169,15 +197,11 @@ class Embed extends Abstract_Block_Renderer {
$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.
+ // Fetch failed or over the fetch cap — 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 );
@@ -359,6 +383,12 @@ class Embed extends Abstract_Block_Renderer {
return __( 'Watch on YouTube', 'woocommerce' );
case 'videopress':
return __( 'Watch on VideoPress', 'woocommerce' );
+ case 'vimeo':
+ return __( 'Watch on Vimeo', 'woocommerce' );
+ case 'tiktok':
+ return __( 'Watch on TikTok', 'woocommerce' );
+ case 'dailymotion':
+ return __( 'Watch on Dailymotion', 'woocommerce' );
default:
return __( 'Listen to the audio', 'woocommerce' );
}
@@ -563,17 +593,15 @@ class Embed extends Abstract_Block_Renderer {
* @return string Thumbnail URL or empty string.
*/
private function get_video_thumbnail_url( string $url, string $provider ): string {
+ // YouTube thumbnails follow a predictable URL pattern, so no HTTP request is needed.
if ( 'youtube' === $provider ) {
return $this->get_youtube_thumbnail( $url );
}
- if ( 'videopress' === $provider ) {
- return $this->get_videopress_thumbnail( $url );
- }
-
- // For other providers, we don't have thumbnail extraction implemented.
- // Return empty to trigger link fallback.
- return '';
+ // All other supported video providers (VideoPress, Vimeo, TikTok, Dailymotion)
+ // expose their thumbnails through the WordPress oEmbed API.
+ // Returns empty string on failure, which triggers the link fallback.
+ return $this->get_oembed_thumbnail( $url );
}
/**
@@ -600,18 +628,19 @@ class Embed extends Abstract_Block_Renderer {
}
/**
- * Extract VideoPress video thumbnail URL.
- * Uses WordPress oEmbed API to get thumbnail_url from the provider response.
+ * Extract a video thumbnail URL via the WordPress oEmbed API.
+ * Used by providers that expose thumbnails through oEmbed (e.g. VideoPress, Vimeo).
* Results are cached using transients to avoid repeated HTTP requests.
*
- * Note: URL validation against VideoPress domains is done in render_video_embed()
+ * Note: URL validation against the provider's domains is done in render_video_embed()
* via url_matches_provider() before this method is called.
*
- * @param string $url VideoPress video URL (pre-validated by caller).
+ * @param string $url Video URL (pre-validated by caller).
* @return string Thumbnail URL or empty string.
*/
- private function get_videopress_thumbnail( string $url ): string {
+ private function get_oembed_thumbnail( string $url ): string {
// Generate a cache key based on the URL.
+ // The legacy "vp" prefix is kept so existing VideoPress caches survive; the key is used for all oEmbed video thumbnails.
$cache_key = 'wc_email_vp_thumb_' . md5( $url );
// Check for cached thumbnail URL.
@@ -621,9 +650,15 @@ class Embed extends Abstract_Block_Renderer {
return is_string( $cached_thumbnail ) ? $cached_thumbnail : '';
}
+ // Enforce the per-render fetch cap before making an HTTP request.
+ // The result is intentionally not cached here, so the thumbnail can still be fetched on a later render.
+ if ( ! $this->may_attempt_embed_fetch() ) {
+ return '';
+ }
+
// Use WP_oEmbed::get_data() to fetch thumbnail from oEmbed endpoint.
// URL is pre-validated by render_video_embed() via url_matches_provider(),
- // ensuring only VideoPress domains reach this point (SSRF mitigation).
+ // ensuring only the matched provider's domains reach this point (SSRF mitigation).
$oembed = new \WP_oEmbed();
$oembed_data = $oembed->get_data( $url );
@@ -661,7 +696,7 @@ class Embed extends Abstract_Block_Renderer {
$thumbnail_url = $oembed_data->thumbnail_url;
// Validate the thumbnail URL.
- if ( ! empty( $thumbnail_url ) && $this->is_valid_url( $thumbnail_url ) ) {
+ if ( is_string( $thumbnail_url ) && '' !== $thumbnail_url && $this->is_valid_url( $thumbnail_url ) ) {
// Cache the valid thumbnail URL.
set_transient( $cache_key, $thumbnail_url, $cache_ttl );
return $thumbnail_url;
@@ -727,6 +762,12 @@ class Embed extends Abstract_Block_Renderer {
return $empty_result;
}
+ // Enforce the per-render fetch cap before making an HTTP request.
+ // The result is intentionally not cached, so the page can still be fetched on a later render.
+ if ( ! $this->may_attempt_embed_fetch() ) {
+ 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(), '' );
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 3cfd6c718c3..4706425f304 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
@@ -725,6 +725,165 @@ class Embed_Test extends \Email_Editor_Integration_Test_Case {
$this->assertStringContainsString( 'background-image', $rendered, 'VideoPress embed should have background image' );
}
+ /**
+ * Helper to mock the oEmbed HTTP response for video thumbnail lookups.
+ *
+ * @param string $endpoint_fragment Substring identifying the provider's oEmbed endpoint URL.
+ * @param string $thumbnail_url Thumbnail URL to return in the mocked response.
+ * @return callable The HTTP filter callback (for removal in cleanup).
+ */
+ private function mock_oembed_thumbnail_response( string $endpoint_fragment, string $thumbnail_url ): callable {
+ $mock_oembed_response = wp_json_encode(
+ array(
+ 'type' => 'video',
+ 'thumbnail_url' => $thumbnail_url,
+ 'title' => 'Test Video',
+ )
+ );
+
+ // Use pre_http_request filter to intercept oEmbed HTTP calls.
+ $filter_callback = function ( $preempt, $args, $url ) use ( $endpoint_fragment, $mock_oembed_response ) {
+ if ( strpos( $url, $endpoint_fragment ) !== false ) {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => $mock_oembed_response,
+ );
+ }
+ return $preempt;
+ };
+
+ add_filter( 'pre_http_request', $filter_callback, 10, 3 );
+
+ return $filter_callback;
+ }
+
+ /**
+ * Data provider for video provider oEmbed thumbnail rendering tests.
+ *
+ * @return array<string, array{array, string, string, array<string>}>
+ */
+ public function video_embed_provider(): array {
+ return array(
+ // Vimeo URLs carry query parameters to verify they survive rendering (as & in HTML).
+ 'vimeo' => array(
+ array(
+ 'url' => 'https://vimeo.com/123456789?w=500&h=281',
+ 'providerNameSlug' => 'vimeo',
+ ),
+ 'vimeo.com/api/oembed',
+ 'https://i.vimeocdn.com/video/123456789.jpg?w=500&h=281',
+ array( 'w=500', 'h=281' ),
+ ),
+ 'tiktok' => array(
+ array(
+ 'url' => 'https://www.tiktok.com/@wordpress/video/7228005059881544986',
+ 'providerNameSlug' => 'tiktok',
+ ),
+ 'tiktok.com/oembed',
+ 'https://p16-sign.tiktokcdn-us.com/obj/tos-useast5-p-0068-tx/thumbnail.jpg',
+ array(),
+ ),
+ // No providerNameSlug, so the dai.ly short URL exercises domain-based detection.
+ 'dailymotion dai.ly' => array(
+ array( 'url' => 'https://dai.ly/x8x9abc' ),
+ 'dailymotion.com/services/oembed',
+ 'https://s1.dmcdn.net/v/X8x9abc/x720-thumbnail.jpg',
+ array(),
+ ),
+ );
+ }
+
+ /**
+ * Test that supported video provider embeds render as a video thumbnail with play button via the oEmbed API.
+ *
+ * @dataProvider video_embed_provider
+ * @param array $attrs Block attributes.
+ * @param string $endpoint_fragment Substring identifying the provider's oEmbed endpoint URL.
+ * @param string $thumbnail_url Thumbnail URL returned by the mocked oEmbed response.
+ * @param array<string> $extra_expected_strings Additional strings expected in the rendered output.
+ */
+ public function test_renders_video_provider_embed_via_oembed( array $attrs, string $endpoint_fragment, string $thumbnail_url, array $extra_expected_strings ): void {
+ // Mock the oEmbed HTTP response to avoid external calls in CI.
+ $filter_callback = $this->mock_oembed_thumbnail_response( $endpoint_fragment, $thumbnail_url );
+
+ $parsed_embed = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => $attrs,
+ 'innerHTML' => '<figure class="wp-block-embed is-type-video"><div class="wp-block-embed__wrapper">' . $attrs['url'] . '</div></figure>',
+ );
+
+ try {
+ $rendered = $this->embed_renderer->render( $parsed_embed['innerHTML'], $parsed_embed, $this->rendering_context );
+ } finally {
+ remove_filter( 'pre_http_request', $filter_callback, 10 );
+ }
+
+ // Should detect the provider and render as video with play button and thumbnail.
+ $this->assertNotEmpty( $rendered );
+ $this->assertStringContainsString( 'play2x.png', $rendered, 'Embed should render with play button' );
+ // Verify background-image is present (not stripped by WP_Style_Engine).
+ $this->assertStringContainsString( 'background-image', $rendered, 'Background image should be present in CSS' );
+ foreach ( $extra_expected_strings as $expected_string ) {
+ $this->assertStringContainsString( $expected_string, $rendered, 'Expected string missing from rendered embed' );
+ }
+ }
+
+ /**
+ * Test that oEmbed thumbnail lookups count toward the per-render HTTP fetch cap.
+ */
+ public function test_caps_oembed_thumbnail_fetches_at_five_per_render(): void {
+ $request_count = 0;
+ $filter_callback = function ( $preempt, $args, $url ) use ( &$request_count ) {
+ if ( strpos( $url, 'vimeo.com/api/oembed' ) !== false ) {
+ ++$request_count;
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => wp_json_encode(
+ array(
+ 'type' => 'video',
+ 'thumbnail_url' => 'https://i.vimeocdn.com/video/123456789.jpg',
+ )
+ ),
+ );
+ }
+ return $preempt;
+ };
+ add_filter( 'pre_http_request', $filter_callback, 10, 3 );
+
+ $urls = array();
+ try {
+ for ( $i = 1; $i <= 6; $i++ ) {
+ $url = 'https://vimeo.com/10000000' . $i;
+ $urls[] = $url;
+
+ $parsed_block = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'url' => $url,
+ 'providerNameSlug' => 'vimeo',
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-video"><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( 'background-image', $rendered, "Embed #{$i} should render with a thumbnail" );
+ } else {
+ $this->assertStringNotContainsString( 'background-image', $rendered, 'Embed #6 should NOT render with a thumbnail' );
+ $this->assertStringContainsString( '<a href="https://vimeo.com/100000006"', $rendered, 'Embed #6 should render as a link fallback' );
+ }
+ }
+ } finally {
+ remove_filter( 'pre_http_request', $filter_callback, 10 );
+ foreach ( $urls as $url ) {
+ delete_transient( 'wc_email_vp_thumb_' . md5( $url ) );
+ }
+ }
+
+ $this->assertSame( 5, $request_count, 'Only five oEmbed thumbnail fetches should be made per render' );
+ }
+
/**
* Helper to mock the embed page HTTP response for example.com URLs.
*
@@ -1141,6 +1300,73 @@ class Embed_Test extends \Email_Editor_Integration_Test_Case {
}
}
+ /**
+ * Test that cached rich cards render even after the per-render fetch cap is exhausted.
+ */
+ public function test_renders_cached_rich_card_after_fetch_cap_exhausted(): void {
+ // Pre-warm the embed page cache for one URL to simulate a previous render.
+ $cached_url = 'https://example.com/cached-post';
+ set_transient(
+ 'wc_email_embed_pg_' . md5( $cached_url ),
+ array(
+ 'title' => 'Cached Card Title',
+ 'thumbnail_url' => '',
+ 'provider_name' => 'Example Blog',
+ 'provider_url' => 'https://example.com',
+ 'excerpt' => '',
+ 'site_icon_url' => '',
+ ),
+ DAY_IN_SECONDS
+ );
+
+ $embed_page_html = '<html><body><div class="wp-embed">'
+ . '<p class="wp-embed-heading"><a href="https://example.com/post" target="_top">Fetched 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 {
+ // Exhaust the five fetch slots with uncached embeds.
+ for ( $i = 1; $i <= 5; $i++ ) {
+ $url = 'https://example.com/uncached-' . $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 cached embed should still render as a rich card without an HTTP fetch.
+ $parsed_cached_block = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'url' => $cached_url,
+ 'type' => 'wp-embed',
+ 'providerNameSlug' => 'example-blog',
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-wp-embed"><div class="wp-block-embed__wrapper">' . $cached_url . '</div></figure>',
+ );
+ $rendered = $this->embed_renderer->render( $parsed_cached_block['innerHTML'], $parsed_cached_block, $this->rendering_context );
+ } finally {
+ remove_filter( 'pre_http_request', $filter_callback, 10 );
+ delete_transient( 'wc_email_embed_pg_' . md5( $cached_url ) );
+ foreach ( $urls as $url ) {
+ delete_transient( 'wc_email_embed_pg_' . md5( $url ) );
+ }
+ }
+
+ $this->assertStringContainsString( 'Cached Card Title', $rendered, 'Cached embed should render as a rich card after the fetch cap is exhausted' );
+ }
+
/**
* Test that compact link card shows URL in a styled card with theme link color
*/