Commit 4043acea3f for woocommerce
commit 4043acea3f1d8de6d66ef4b3a9a622dc4d19b51f
Author: Allison Levine <1689238+allilevine@users.noreply.github.com>
Date: Thu Sep 18 11:07:20 2025 -0400
Email Editor: Add video block and video embed email rendering (#60957)
* Email Editor: Add video block email rendering with YouTube embed support
- Add Video block renderer that extends Cover block for email-friendly video thumbnails
- Add YouTube embed support to Embed block renderer with thumbnail extraction
- Add play button icon and proper email-compatible styling
- Add comprehensive test coverage for YouTube embeds and video blocks
- Update Embed block to detect and render video providers as video blocks
- Add changelog entry for video email rendering feature
This enables video blocks and YouTube embeds to render properly in emails
with thumbnail images and play button overlays using email-compatible HTML.
* Move play icon to the correct directory.
* Refactor embed class to reduce repitition.
* Link to video and fall back to post link.
* Add clarifying comment and noreferrer.
diff --git a/packages/php/email-editor/changelog/add-video-email-rendering b/packages/php/email-editor/changelog/add-video-email-rendering
new file mode 100644
index 0000000000..24777d2861
--- /dev/null
+++ b/packages/php/email-editor/changelog/add-video-email-rendering
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add email rendering instructions for the core/video block and YouTube embeds.
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 893b294b50..f28e8b4309 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
@@ -10,13 +10,120 @@ namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
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;
/**
* Embed block renderer.
- * This renderer handles core/embed blocks, specifically detecting audio provider embeds (Spotify, SoundCloud, Pocket Casts, Mixcloud, ReverbNation) and rendering them as audio players.
+ * 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.
*/
class Embed extends Abstract_Block_Renderer {
+ /**
+ * Supported audio providers with their configuration.
+ *
+ * @var array
+ */
+ private const AUDIO_PROVIDERS = array(
+ 'pocket-casts' => array(
+ 'domains' => array( 'pca.st' ),
+ 'base_url' => 'https://pca.st/',
+ ),
+ 'spotify' => array(
+ 'domains' => array( 'open.spotify.com' ),
+ 'base_url' => 'https://open.spotify.com/',
+ ),
+ 'soundcloud' => array(
+ 'domains' => array( 'soundcloud.com' ),
+ 'base_url' => 'https://soundcloud.com/',
+ ),
+ 'mixcloud' => array(
+ 'domains' => array( 'mixcloud.com' ),
+ 'base_url' => 'https://www.mixcloud.com/',
+ ),
+ 'reverbnation' => array(
+ 'domains' => array( 'reverbnation.com' ),
+ 'base_url' => 'https://www.reverbnation.com/',
+ ),
+ );
+
+ /**
+ * Supported video providers with their configuration.
+ *
+ * @var array
+ */
+ private const VIDEO_PROVIDERS = array(
+ 'youtube' => array(
+ 'domains' => array( 'youtube.com', 'youtu.be' ),
+ 'base_url' => 'https://www.youtube.com/',
+ ),
+ );
+
+ /**
+ * Get all supported providers (audio and video).
+ *
+ * @return array All supported providers.
+ */
+ private function get_all_supported_providers(): array {
+ return array_merge( array_keys( self::AUDIO_PROVIDERS ), array_keys( self::VIDEO_PROVIDERS ) );
+ }
+
+ /**
+ * Get all provider configurations (audio and video).
+ *
+ * @return array All provider configurations.
+ */
+ private function get_all_provider_configs(): array {
+ return array_merge( self::AUDIO_PROVIDERS, self::VIDEO_PROVIDERS );
+ }
+
+ /**
+ * Detect provider from content by checking against known domains.
+ *
+ * @param string $content Content to check for provider domains.
+ * @return string Provider name or empty string if not found.
+ */
+ private function detect_provider_from_domains( string $content ): string {
+ $all_providers = $this->get_all_provider_configs();
+
+ foreach ( $all_providers as $provider => $config ) {
+ foreach ( $config['domains'] as $domain ) {
+ if ( strpos( $content, $domain ) !== false ) {
+ return $provider;
+ }
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * Validate URL using both filter_var and wp_http_validate_url.
+ *
+ * @param string $url URL to validate.
+ * @return bool True if URL is valid.
+ */
+ private function is_valid_url( string $url ): bool {
+ return ! empty( $url ) && filter_var( $url, FILTER_VALIDATE_URL ) && wp_http_validate_url( $url );
+ }
+
+ /**
+ * Create fallback attributes for link rendering.
+ *
+ * @param string $url URL for the fallback.
+ * @param string $label Label for the fallback.
+ * @return array Fallback attributes.
+ */
+ private function create_fallback_attributes( string $url, string $label ): array {
+ return array(
+ 'url' => $url,
+ 'label' => $label,
+ );
+ }
+
/**
* Renders the embed block.
*
@@ -34,21 +141,20 @@ class Embed extends Abstract_Block_Renderer {
$attr = $parsed_block['attrs'];
- // Check if this is a supported audio provider embed and has a valid URL.
+ // 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-audio embeds, try to render as a simple link fallback.
+ // For non-supported embeds, try to render as a simple link fallback.
return $this->render_link_fallback( $attr, $block_content, $parsed_block, $rendering_context );
}
- $url = $this->extract_provider_url( $attr, $block_content, $provider );
+ $url = $this->extract_provider_url( $attr, $block_content );
if ( empty( $url ) ) {
// Provider was detected but URL extraction failed - provide graceful fallback.
return $this->render_link_fallback( $attr, $block_content, $parsed_block, $rendering_context );
}
- // If we have a valid audio provider embed, proceed with normal rendering.
- // Note: Audio::render already wraps its output with add_spacer, so we return directly.
+ // If we have a valid audio or video provider embed, proceed with normal rendering.
return $this->render_content( $block_content, $parsed_block, $rendering_context );
}
@@ -65,9 +171,14 @@ class Embed extends Abstract_Block_Renderer {
// Get provider and URL (validation already done in render method).
$provider = $this->get_supported_provider( $attr, $block_content );
- $url = $this->extract_provider_url( $attr, $block_content, $provider );
+ $url = $this->extract_provider_url( $attr, $block_content );
+
+ // Check if this is a video provider - render as video block.
+ if ( $this->is_video_provider( $provider ) ) {
+ return $this->render_video_embed( $url, $provider, $parsed_block, $rendering_context, $block_content );
+ }
- // Get appropriate label for the provider.
+ // For audio providers, use the original audio rendering logic.
$label = $this->get_provider_label( $provider, $attr );
// Create a mock audio block structure to reuse the Audio renderer.
@@ -91,12 +202,7 @@ class Embed extends Abstract_Block_Renderer {
// If audio rendering fails, fall back to a simple link.
if ( empty( $audio_result ) ) {
- // Use the existing render_link_fallback method for consistent spacing and formatting.
- // Create a mock attr array with the URL and label for the fallback method.
- $fallback_attr = array(
- 'url' => $url,
- 'label' => $label,
- );
+ $fallback_attr = $this->create_fallback_attributes( $url, $label );
return $this->render_link_fallback( $fallback_attr, $block_content, $parsed_block, $rendering_context );
}
@@ -104,17 +210,17 @@ class Embed extends Abstract_Block_Renderer {
}
/**
- * Get supported audio provider from block attributes or content.
+ * Get supported audio or video provider from block attributes or content.
*
* @param array $attr Block attributes.
* @param string $block_content Block content.
* @return string Provider name or empty string if not supported.
*/
private function get_supported_provider( array $attr, string $block_content ): string {
- $supported_providers = array( 'pocket-casts', 'spotify', 'soundcloud', 'mixcloud', 'reverbnation' );
+ $all_supported_providers = $this->get_all_supported_providers();
// Check provider name slug.
- if ( isset( $attr['providerNameSlug'] ) && in_array( $attr['providerNameSlug'], $supported_providers, true ) ) {
+ if ( isset( $attr['providerNameSlug'] ) && in_array( $attr['providerNameSlug'], $all_supported_providers, true ) ) {
return $attr['providerNameSlug'];
}
@@ -122,20 +228,36 @@ class Embed extends Abstract_Block_Renderer {
$url = $attr['url'] ?? '';
$content_to_check = ! empty( $url ) ? $url : $block_content;
- if ( strpos( $content_to_check, 'open.spotify.com' ) !== false ) {
- return 'spotify';
- }
- if ( strpos( $content_to_check, 'soundcloud.com' ) !== false ) {
- return 'soundcloud';
- }
- if ( strpos( $content_to_check, 'pca.st' ) !== false ) {
- return 'pocket-casts';
- }
- if ( strpos( $content_to_check, 'mixcloud.com' ) !== false ) {
- return 'mixcloud';
- }
- if ( strpos( $content_to_check, 'reverbnation.com' ) !== false ) {
- return 'reverbnation';
+ // Use sophisticated domain detection logic.
+ return $this->detect_provider_from_domains( $content_to_check );
+ }
+
+ /**
+ * Extract URL from block content using DOM parsing.
+ *
+ * @param string $block_content Block content HTML.
+ * @return string Extracted URL or empty string.
+ */
+ private function extract_url_from_content( string $block_content ): string {
+ $dom_helper = new Dom_Document_Helper( $block_content );
+
+ // Find the wp-block-embed__wrapper div.
+ $wrapper_element = $dom_helper->find_element( 'div' );
+ if ( $wrapper_element ) {
+ // Check if this div has the correct class.
+ $class_attr = $dom_helper->get_attribute_value( $wrapper_element, 'class' );
+ if ( strpos( $class_attr, 'wp-block-embed__wrapper' ) !== false ) {
+ // Get the text content (URL) from the div.
+ $url = trim( $wrapper_element->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+
+ // Decode HTML entities and validate URL.
+ $url = html_entity_decode( $url, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
+
+ // Validate the extracted URL.
+ if ( $this->is_valid_url( $url ) ) {
+ return $url;
+ }
+ }
}
return '';
@@ -146,31 +268,21 @@ class Embed extends Abstract_Block_Renderer {
*
* @param array $attr Block attributes.
* @param string $block_content Block content.
- * @param string $provider Provider name.
* @return string Provider URL or empty string.
*/
- private function extract_provider_url( array $attr, string $block_content, string $provider ): string {
+ private function extract_provider_url( array $attr, string $block_content ): string {
// First, try to get URL from attributes.
if ( ! empty( $attr['url'] ) ) {
$url = $attr['url'];
// Validate the URL from attributes.
- if ( filter_var( $url, FILTER_VALIDATE_URL ) && wp_http_validate_url( $url ) ) {
+ if ( $this->is_valid_url( $url ) ) {
return $url;
}
return '';
}
- // If not in attributes, extract from block content using simple pattern.
- // The innerHTML always contains the URL in a predictable structure.
- if ( preg_match( '/<div class="wp-block-embed__wrapper">([^<]+)<\/div>/', $block_content, $matches ) ) {
- $url = trim( $matches[1] );
- // Validate the extracted URL.
- if ( filter_var( $url, FILTER_VALIDATE_URL ) && wp_http_validate_url( $url ) ) {
- return $url;
- }
- }
-
- return '';
+ // If not in attributes, extract from block content.
+ return $this->extract_url_from_content( $block_content );
}
/**
@@ -186,7 +298,17 @@ class Embed extends Abstract_Block_Renderer {
return $attr['label'];
}
- // Use default label based on provider.
+ // Get translated label for the provider.
+ return $this->get_translated_provider_label( $provider );
+ }
+
+ /**
+ * Get translated label for a provider.
+ *
+ * @param string $provider Provider name.
+ * @return string Translated label for the provider.
+ */
+ private function get_translated_provider_label( string $provider ): string {
switch ( $provider ) {
case 'spotify':
return __( 'Listen on Spotify', 'woocommerce' );
@@ -198,13 +320,15 @@ class Embed extends Abstract_Block_Renderer {
return __( 'Listen on Mixcloud', 'woocommerce' );
case 'reverbnation':
return __( 'Listen on ReverbNation', 'woocommerce' );
+ case 'youtube':
+ return __( 'Watch on YouTube', 'woocommerce' );
default:
return __( 'Listen to the audio', 'woocommerce' );
}
}
/**
- * Render a simple link fallback for non-audio embeds.
+ * Render a simple link fallback for non-supported embeds.
*
* @param array $attr Block attributes.
* @param string $block_content Block content.
@@ -218,9 +342,21 @@ class Embed extends Abstract_Block_Renderer {
// If no URL in attributes, try to extract from block content.
if ( empty( $url ) ) {
- // Look for any HTTP/HTTPS URL in the content with proper boundaries.
- if ( preg_match( '/(?<![a-zA-Z0-9.-])https?:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}[a-zA-Z0-9\/?=&%-]*(?![a-zA-Z0-9.-])/', $block_content, $matches ) ) {
- $url = $matches[0];
+ // First try the standard wrapper div extraction.
+ $url = $this->extract_url_from_content( $block_content );
+
+ // If still no URL, try to find any HTTP/HTTPS URL in the entire content.
+ if ( empty( $url ) ) {
+ $dom_helper = new Dom_Document_Helper( $block_content );
+ $body_element = $dom_helper->find_element( 'body' );
+ if ( $body_element ) {
+ $text_content = $body_element->textContent; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+
+ // Look for HTTP/HTTPS URLs in the text content.
+ if ( preg_match( '/(?<![a-zA-Z0-9.-])https?:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}[a-zA-Z0-9\/?=&%-]*(?![a-zA-Z0-9.-])/', $text_content, $matches ) ) {
+ $url = $matches[0];
+ }
+ }
}
}
@@ -230,7 +366,7 @@ class Embed extends Abstract_Block_Renderer {
}
// Validate URL with both filter_var and wp_http_validate_url.
- if ( empty( $url ) || ! filter_var( $url, FILTER_VALIDATE_URL ) || ! wp_http_validate_url( $url ) ) {
+ if ( ! $this->is_valid_url( $url ) ) {
return '';
}
@@ -279,19 +415,105 @@ class Embed extends Abstract_Block_Renderer {
* @return string Base URL for the provider or empty string.
*/
private function get_provider_base_url( string $provider ): string {
- switch ( $provider ) {
- case 'spotify':
- return 'https://open.spotify.com/';
- case 'soundcloud':
- return 'https://soundcloud.com/';
- case 'pocket-casts':
- return 'https://pca.st/';
- case 'mixcloud':
- return 'https://www.mixcloud.com/';
- case 'reverbnation':
- return 'https://www.reverbnation.com/';
- default:
- return '';
+ $all_providers = $this->get_all_provider_configs();
+ return $all_providers[ $provider ]['base_url'] ?? '';
+ }
+
+ /**
+ * Check if a provider is a video provider.
+ *
+ * @param string $provider Provider name.
+ * @return bool True if video provider.
+ */
+ private function is_video_provider( string $provider ): bool {
+ return array_key_exists( $provider, self::VIDEO_PROVIDERS );
+ }
+
+ /**
+ * Render a video embed using the Video renderer.
+ *
+ * @param string $url URL of the video.
+ * @param string $provider Provider name.
+ * @param array $parsed_block Parsed block.
+ * @param Rendering_Context $rendering_context Rendering context.
+ * @param string $block_content Original block content.
+ * @return string Rendered video embed or fallback.
+ */
+ private function render_video_embed( string $url, string $provider, array $parsed_block, Rendering_Context $rendering_context, string $block_content ): string {
+ // Try to get video thumbnail URL.
+ $poster_url = $this->get_video_thumbnail_url( $url, $provider );
+
+ // If no poster available, fall back to a simple link.
+ if ( empty( $poster_url ) ) {
+ $fallback_attr = $this->create_fallback_attributes( $url, $url );
+ return $this->render_link_fallback( $fallback_attr, $block_content, $parsed_block, $rendering_context );
}
+
+ // Create a mock video block structure to reuse the Video renderer.
+ $mock_video_block = array(
+ 'blockName' => 'core/video',
+ 'attrs' => array(
+ 'poster' => $poster_url,
+ ),
+ 'innerHTML' => '<figure class="wp-block-video wp-block-embed is-type-video is-provider-' . esc_attr( $provider ) . '"><div class="wp-block-embed__wrapper">' . esc_url( $url ) . '</div></figure>',
+ );
+
+ // Copy email attributes to the mock block.
+ if ( isset( $parsed_block['email_attrs'] ) ) {
+ $mock_video_block['email_attrs'] = $parsed_block['email_attrs'];
+ }
+
+ // Use the Video renderer to render the video provider embed.
+ $video_renderer = new Video();
+ $video_result = $video_renderer->render( $mock_video_block['innerHTML'], $mock_video_block, $rendering_context );
+
+ // If video rendering fails, fall back to a simple link.
+ if ( empty( $video_result ) ) {
+ $fallback_attr = $this->create_fallback_attributes( $url, $url );
+ return $this->render_link_fallback( $fallback_attr, $block_content, $parsed_block, $rendering_context );
+ }
+
+ return $video_result;
+ }
+
+ /**
+ * Get video thumbnail URL for supported providers.
+ *
+ * @param string $url Video URL.
+ * @param string $provider Provider name.
+ * @return string Thumbnail URL or empty string.
+ */
+ private function get_video_thumbnail_url( string $url, string $provider ): string {
+ // Currently only YouTube supports thumbnail extraction.
+ if ( 'youtube' === $provider ) {
+ return $this->get_youtube_thumbnail( $url );
+ }
+
+ // For other providers, we don't have thumbnail extraction implemented.
+ // Return empty to trigger link fallback.
+ return '';
+ }
+
+ /**
+ * Extract YouTube video thumbnail URL.
+ *
+ * @param string $url YouTube video URL.
+ * @return string Thumbnail URL or empty string.
+ */
+ private function get_youtube_thumbnail( string $url ): string {
+ // Extract video ID from various YouTube URL formats.
+ $video_id = '';
+
+ if ( preg_match( '/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]+)/', $url, $matches ) ) {
+ $video_id = $matches[1];
+ }
+
+ if ( empty( $video_id ) ) {
+ return '';
+ }
+
+ // Return YouTube thumbnail URL.
+ // Using 0.jpg format as shown in the example.
+ return 'https://img.youtube.com/vi/' . $video_id . '/0.jpg';
}
}
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-video.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-video.php
new file mode 100644
index 0000000000..06312ff744
--- /dev/null
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-video.php
@@ -0,0 +1,208 @@
+<?php
+/**
+ * This file is part of the WooCommerce Email Editor package
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare( strict_types = 1 );
+namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
+use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
+
+/**
+ * Video block renderer.
+ * This renderer handles core/video blocks by reusing the cover block renderer
+ * to show a thumbnail with a play button overlay.
+ */
+class Video extends Cover {
+ /**
+ * Renders the video block content by transforming it into a cover block structure.
+ * Shows the video poster/thumbnail with a play button overlay using the parent cover renderer.
+ *
+ * @param string $block_content Block content.
+ * @param array $parsed_block Parsed block.
+ * @param Rendering_Context $rendering_context Rendering context.
+ * @return string
+ */
+ protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
+ $block_attrs = $parsed_block['attrs'] ?? array();
+
+ // Extract poster URL from video attributes.
+ $poster_url = $this->extract_poster_url( $block_attrs, $block_content );
+
+ // If no poster image, return empty content.
+ if ( empty( $poster_url ) ) {
+ return '';
+ }
+
+ // Transform video block into cover block structure and delegate to parent.
+ $cover_block = $this->transform_to_cover_block( $parsed_block, $poster_url );
+ return parent::render_content( $block_content, $cover_block, $rendering_context );
+ }
+
+ /**
+ * Extract poster URL from block attributes.
+ *
+ * @param array $block_attrs Block attributes.
+ * @param string $block_content Original block content (unused, kept for consistency).
+ * @return string Poster URL or empty string.
+ */
+ private function extract_poster_url( array $block_attrs, string $block_content ): string {
+ // Check for poster attribute.
+ if ( ! empty( $block_attrs['poster'] ) ) {
+ return esc_url( $block_attrs['poster'] );
+ }
+
+ return '';
+ }
+
+ /**
+ * Extract video URL from block content.
+ *
+ * @param string $block_content Block content HTML.
+ * @return string Video URL or empty string.
+ */
+ private function extract_video_url( string $block_content ): string {
+ // Use Dom_Document_Helper for robust HTML parsing.
+ $dom_helper = new Dom_Document_Helper( $block_content );
+
+ // Find the wp-block-embed__wrapper div.
+ $wrapper_element = $dom_helper->find_element( 'div' );
+ if ( ! $wrapper_element ) {
+ return '';
+ }
+
+ // Check if this div has the correct class.
+ $class_attr = $dom_helper->get_attribute_value( $wrapper_element, 'class' );
+ if ( strpos( $class_attr, 'wp-block-embed__wrapper' ) === false ) {
+ return '';
+ }
+
+ // Get the inner HTML content from the wrapper div.
+ $inner_html = $dom_helper->get_element_inner_html( $wrapper_element );
+
+ // Look for HTTP/HTTPS URLs in the inner HTML content.
+ if ( preg_match( '/(?<![a-zA-Z0-9.-])https?:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}[a-zA-Z0-9\/?=&%-]*(?![a-zA-Z0-9.-])/', $inner_html, $matches ) ) {
+ $url = $matches[0];
+
+ // Decode HTML entities and validate URL.
+ $url = html_entity_decode( $url, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
+
+ // Validate the URL.
+ if ( filter_var( $url, FILTER_VALIDATE_URL ) && wp_http_validate_url( $url ) ) {
+ return $url;
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * Transform a video block into a cover block structure.
+ *
+ * @param array $video_block Original video block.
+ * @param string $poster_url Poster URL to use as background.
+ * @return array Cover block structure.
+ */
+ private function transform_to_cover_block( array $video_block, string $poster_url ): array {
+ $block_attrs = $video_block['attrs'] ?? array();
+ $block_content = $video_block['innerHTML'] ?? '';
+
+ // Extract video URL from block content, fall back to post URL.
+ // Priority: 1) Video URL (if found), 2) Post permalink (fallback).
+ $video_url = $this->extract_video_url( $block_content );
+ $link_url = ! empty( $video_url ) ? $video_url : $this->get_current_post_url();
+
+ return array(
+ 'blockName' => 'core/cover',
+ 'attrs' => array(
+ 'url' => $poster_url,
+ 'minHeight' => '390px', // Custom attribute for video blocks.
+ ),
+ 'innerBlocks' => array(
+ array(
+ 'blockName' => 'core/html',
+ 'attrs' => array(),
+ 'innerBlocks' => array(),
+ 'innerHTML' => $this->create_play_button_html( $link_url ),
+ 'innerContent' => array( $this->create_play_button_html( $link_url ) ),
+ ),
+ ),
+ 'innerHTML' => $block_content,
+ );
+ }
+
+ /**
+ * Create the play button HTML with optional link.
+ *
+ * @param string $link_url Optional URL to link to.
+ * @return string Play button HTML.
+ */
+ private function create_play_button_html( string $link_url = '' ): string {
+ $play_icon_url = $this->get_play_icon_url();
+
+ $play_button = sprintf(
+ '<img src="%s" alt="%s" style="width: 48px; height: 48px; display: inline-block;" />',
+ esc_url( $play_icon_url ),
+ // translators: Alt text for video play button icon.
+ esc_attr( __( 'Play', 'woocommerce' ) )
+ );
+
+ // Wrap the play button in a link if URL is provided.
+ if ( ! empty( $link_url ) ) {
+ $play_button = sprintf(
+ '<a href="%s" target="_blank" rel="noopener noreferrer nofollow" style="display: inline-block; text-decoration: none;">%s</a>',
+ esc_url( $link_url ),
+ $play_button
+ );
+ }
+
+ return sprintf(
+ '<p style="text-align: center;">%s</p>',
+ $play_button
+ );
+ }
+
+ /**
+ * Get the URL for the play button icon.
+ *
+ * @return string Play button icon URL.
+ */
+ private function get_play_icon_url(): string {
+ $file_name = '/icons/video/play2x.png';
+ return plugins_url( $file_name, __FILE__ );
+ }
+
+ /**
+ * Get the current post permalink with security validation.
+ *
+ * @return string Post permalink or empty string if invalid.
+ */
+ private function get_current_post_url(): string {
+ global $post;
+
+ if ( ! $post instanceof \WP_Post ) {
+ return '';
+ }
+
+ $permalink = get_permalink( $post->ID );
+ if ( empty( $permalink ) ) {
+ return '';
+ }
+
+ // Validate URL type and format (following audio block pattern).
+ if ( strpos( $permalink, 'https://' ) !== 0 && strpos( $permalink, 'http://' ) !== 0 ) {
+ // Reject non-HTTP protocols for security.
+ return '';
+ }
+
+ // For all HTTP(S) URLs, validate with wp_http_validate_url.
+ if ( ! wp_http_validate_url( $permalink ) ) {
+ return '';
+ }
+
+ return $permalink;
+ }
+}
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/icons/video/play2x.png b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/icons/video/play2x.png
new file mode 100644
index 0000000000..bff0e047af
Binary files /dev/null and b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/icons/video/play2x.png differ
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 42117ebd44..774cebe927 100644
--- a/packages/php/email-editor/src/Integrations/Core/class-initializer.php
+++ b/packages/php/email-editor/src/Integrations/Core/class-initializer.php
@@ -26,6 +26,7 @@ use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\List_It
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Media_Text;
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Cover;
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Quote;
+use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Video;
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Social_Link;
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Social_Links;
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Table;
@@ -72,6 +73,7 @@ class Initializer {
'core/audio',
'core/embed',
'core/cover',
+ 'core/video',
);
/**
@@ -233,6 +235,9 @@ class Initializer {
case 'core/cover':
$renderer = new Cover();
break;
+ case 'core/video':
+ $renderer = new Video();
+ break;
default:
$renderer = new Fallback();
break;
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 4dde2cb0e9..1425d8f62e 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
@@ -299,26 +299,47 @@ class Embed_Test extends \Email_Editor_Integration_Test_Case {
* Test that non-audio embeds render as link fallback
*/
public function test_renders_non_audio_embeds_as_link_fallback(): void {
- $rendered = $this->embed_renderer->render( $this->parsed_youtube_embed['innerHTML'], $this->parsed_youtube_embed, $this->rendering_context );
+ // Use a non-supported embed provider for this test.
+ $parsed_unsupported_embed = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'url' => 'https://example.com/embed',
+ 'type' => 'rich',
+ 'providerNameSlug' => 'example',
+ 'responsive' => true,
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-rich is-provider-example"><div class="wp-block-embed__wrapper">https://example.com/embed</div></figure>',
+ );
+
+ $rendered = $this->embed_renderer->render( $parsed_unsupported_embed['innerHTML'], $parsed_unsupported_embed, $this->rendering_context );
// Check that the rendered content contains a link.
- $this->assertStringContainsString( '<a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ"', $rendered );
+ $this->assertStringContainsString( '<a href="https://example.com/embed"', $rendered );
$this->assertStringContainsString( 'target="_blank"', $rendered );
$this->assertStringContainsString( 'rel="noopener nofollow"', $rendered );
- $this->assertStringContainsString( 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', $rendered );
+ $this->assertStringContainsString( 'https://example.com/embed', $rendered );
}
/**
* Test that link fallback uses custom label when provided
*/
public function test_link_fallback_uses_custom_label(): void {
- $parsed_youtube_with_label = $this->parsed_youtube_embed;
- $parsed_youtube_with_label['attrs']['label'] = 'Watch this video';
+ $parsed_unsupported_with_label = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'url' => 'https://example.com/embed',
+ 'type' => 'rich',
+ 'providerNameSlug' => 'example',
+ 'responsive' => true,
+ 'label' => 'Watch this video',
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-rich is-provider-example"><div class="wp-block-embed__wrapper">https://example.com/embed</div></figure>',
+ );
- $rendered = $this->embed_renderer->render( $this->parsed_youtube_embed['innerHTML'], $parsed_youtube_with_label, $this->rendering_context );
+ $rendered = $this->embed_renderer->render( $parsed_unsupported_with_label['innerHTML'], $parsed_unsupported_with_label, $this->rendering_context );
$this->assertStringContainsString( 'Watch this video', $rendered );
- $this->assertStringContainsString( '<a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ"', $rendered );
+ $this->assertStringContainsString( '<a href="https://example.com/embed"', $rendered );
// The link text should be the custom label, not the URL.
$this->assertStringContainsString( '>Watch this video</a>', $rendered );
}
@@ -327,19 +348,26 @@ class Embed_Test extends \Email_Editor_Integration_Test_Case {
* Test that link fallback extracts URL from content when not in attributes
*/
public function test_link_fallback_extracts_url_from_content(): void {
- $parsed_youtube_no_url_attr = $this->parsed_youtube_embed;
- unset( $parsed_youtube_no_url_attr['attrs']['url'] );
+ $parsed_unsupported_no_url_attr = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'type' => 'rich',
+ 'providerNameSlug' => 'example',
+ 'responsive' => true,
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-rich is-provider-example"><div class="wp-block-embed__wrapper">https://example.com/embed</div></figure>',
+ );
- $rendered = $this->embed_renderer->render( $this->parsed_youtube_embed['innerHTML'], $parsed_youtube_no_url_attr, $this->rendering_context );
+ $rendered = $this->embed_renderer->render( $parsed_unsupported_no_url_attr['innerHTML'], $parsed_unsupported_no_url_attr, $this->rendering_context );
- $this->assertStringContainsString( '<a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ"', $rendered );
- $this->assertStringContainsString( 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', $rendered );
+ $this->assertStringContainsString( '<a href="https://example.com/embed"', $rendered );
+ $this->assertStringContainsString( 'https://example.com/embed', $rendered );
}
/**
- * Test that link fallback returns empty when no valid URL is found
+ * Test that link fallback returns base URL when no valid URL is found but provider is known
*/
- public function test_link_fallback_returns_empty_when_no_valid_url(): void {
+ public function test_link_fallback_returns_base_url_when_no_valid_url(): void {
$parsed_embed_no_url = array(
'blockName' => 'core/embed',
'attrs' => array(
@@ -350,7 +378,11 @@ class Embed_Test extends \Email_Editor_Integration_Test_Case {
$rendered = $this->embed_renderer->render( $parsed_embed_no_url['innerHTML'], $parsed_embed_no_url, $this->rendering_context );
- $this->assertEmpty( $rendered );
+ // Should return graceful fallback link since provider is detected but no URL is available.
+ $this->assertStringContainsString( '<a href="https://www.youtube.com/"', $rendered );
+ $this->assertStringContainsString( 'Watch on YouTube', $rendered );
+ $this->assertStringContainsString( 'target="_blank"', $rendered );
+ $this->assertStringContainsString( 'rel="noopener nofollow"', $rendered );
}
/**
@@ -396,4 +428,155 @@ class Embed_Test extends \Email_Editor_Integration_Test_Case {
$this->assertStringContainsString( 'Listen on ReverbNation', $rendered );
$this->assertStringContainsString( 'https://reverbnation.com/artist/example-song', $rendered );
}
+
+ /**
+ * Test that YouTube embed renders as video player
+ */
+ public function test_renders_youtube_embed_as_video_player(): void {
+ $rendered = $this->embed_renderer->render( $this->parsed_youtube_embed['innerHTML'], $this->parsed_youtube_embed, $this->rendering_context );
+
+ // Check that the rendered content contains YouTube video elements.
+ $this->assertStringContainsString( 'https://img.youtube.com/vi/dQw4w9WgXcQ/0.jpg', $rendered );
+ $this->assertStringContainsString( 'play2x.png', $rendered );
+ $this->assertStringContainsString( 'background-image:url("https://img.youtube.com/vi/dQw4w9WgXcQ/0.jpg")', $rendered );
+ $this->assertStringContainsString( 'background-size:cover', $rendered );
+ $this->assertStringContainsString( 'min-height:390px', $rendered );
+ }
+
+ /**
+ * Test that YouTube embed uses custom label when provided
+ */
+ public function test_youtube_embed_uses_custom_label_when_provided(): void {
+ $parsed_youtube_custom_label = $this->parsed_youtube_embed;
+ $parsed_youtube_custom_label['attrs']['label'] = 'Watch this video';
+
+ $rendered = $this->embed_renderer->render( $this->parsed_youtube_embed['innerHTML'], $parsed_youtube_custom_label, $this->rendering_context );
+
+ // Custom labels are not used in video rendering - the play button is always "Play".
+ $this->assertStringContainsString( 'alt="Play"', $rendered );
+ }
+
+ /**
+ * Test that YouTube embed handles email attributes for spacing
+ */
+ public function test_youtube_embed_handles_email_attributes_for_spacing(): void {
+ $parsed_youtube_with_spacing = $this->parsed_youtube_embed;
+ $parsed_youtube_with_spacing['email_attrs'] = array(
+ 'margin' => '20px 0',
+ );
+
+ $rendered = $this->embed_renderer->render( $this->parsed_youtube_embed['innerHTML'], $parsed_youtube_with_spacing, $this->rendering_context );
+
+ // Email attributes are handled by the cover block renderer.
+ $this->assertStringContainsString( 'https://img.youtube.com/vi/dQw4w9WgXcQ/0.jpg', $rendered );
+ $this->assertStringContainsString( 'play2x.png', $rendered );
+ }
+
+ /**
+ * Test that YouTube embed includes proper security attributes
+ */
+ public function test_youtube_embed_includes_proper_security_attributes(): void {
+ $rendered = $this->embed_renderer->render( $this->parsed_youtube_embed['innerHTML'], $this->parsed_youtube_embed, $this->rendering_context );
+
+ // The play button may or may not be wrapped in a link depending on post context.
+ // In test environment, there may not be a valid post URL, so the play button is just an image.
+ $this->assertStringContainsString( 'alt="Play"', $rendered );
+ $this->assertStringContainsString( 'play2x.png', $rendered );
+ }
+
+ /**
+ * Test that YouTube embed includes proper accessibility attributes
+ */
+ public function test_youtube_embed_includes_proper_accessibility_attributes(): void {
+ $rendered = $this->embed_renderer->render( $this->parsed_youtube_embed['innerHTML'], $this->parsed_youtube_embed, $this->rendering_context );
+
+ $this->assertStringContainsString( 'alt="Play"', $rendered );
+ }
+
+ /**
+ * Test that YouTube embed detects YouTube by providerNameSlug
+ */
+ public function test_youtube_embed_detects_youtube_by_provider_name_slug(): void {
+ $parsed_youtube_by_slug = $this->parsed_youtube_embed;
+ unset( $parsed_youtube_by_slug['attrs']['url'] );
+ $parsed_youtube_by_slug['innerHTML'] = '<figure class="wp-block-embed is-type-video is-provider-youtube"><div class="wp-block-embed__wrapper">Some content</div></figure>';
+
+ $rendered = $this->embed_renderer->render( $parsed_youtube_by_slug['innerHTML'], $parsed_youtube_by_slug, $this->rendering_context );
+
+ // Should return graceful fallback link since provider is detected but no URL is available for thumbnail extraction.
+ $this->assertStringContainsString( '<a href="https://www.youtube.com/"', $rendered );
+ $this->assertStringContainsString( 'Watch on YouTube', $rendered );
+ $this->assertStringContainsString( 'target="_blank"', $rendered );
+ $this->assertStringContainsString( 'rel="noopener nofollow"', $rendered );
+ }
+
+ /**
+ * Test that YouTube embed detects YouTube by URL in attributes
+ */
+ public function test_youtube_embed_detects_youtube_by_url_in_attributes(): void {
+ $parsed_youtube_by_url = $this->parsed_youtube_embed;
+ unset( $parsed_youtube_by_url['attrs']['providerNameSlug'] );
+
+ $rendered = $this->embed_renderer->render( $this->parsed_youtube_embed['innerHTML'], $parsed_youtube_by_url, $this->rendering_context );
+
+ $this->assertStringContainsString( 'https://img.youtube.com/vi/dQw4w9WgXcQ/0.jpg', $rendered );
+ $this->assertStringContainsString( 'play2x.png', $rendered );
+ }
+
+ /**
+ * Test that YouTube embed detects YouTube by URL in content
+ */
+ public function test_youtube_embed_detects_youtube_by_url_in_content(): void {
+ $parsed_youtube_by_content = $this->parsed_youtube_embed;
+ unset( $parsed_youtube_by_content['attrs']['providerNameSlug'] );
+ unset( $parsed_youtube_by_content['attrs']['url'] );
+
+ $rendered = $this->embed_renderer->render( $this->parsed_youtube_embed['innerHTML'], $parsed_youtube_by_content, $this->rendering_context );
+
+ $this->assertStringContainsString( 'https://img.youtube.com/vi/dQw4w9WgXcQ/0.jpg', $rendered );
+ $this->assertStringContainsString( 'play2x.png', $rendered );
+ }
+
+ /**
+ * Test that YouTube embed handles youtu.be URLs
+ */
+ public function test_youtube_embed_handles_youtu_be_urls(): void {
+ $parsed_youtube_short_url = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'url' => 'https://youtu.be/dQw4w9WgXcQ',
+ 'type' => 'video',
+ 'providerNameSlug' => 'youtube',
+ 'responsive' => true,
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-video is-provider-youtube wp-block-embed-youtube"><div class="wp-block-embed__wrapper">https://youtu.be/dQw4w9WgXcQ</div></figure>',
+ );
+
+ $rendered = $this->embed_renderer->render( $parsed_youtube_short_url['innerHTML'], $parsed_youtube_short_url, $this->rendering_context );
+
+ $this->assertStringContainsString( 'https://img.youtube.com/vi/dQw4w9WgXcQ/0.jpg', $rendered );
+ $this->assertStringContainsString( 'play2x.png', $rendered );
+ }
+
+ /**
+ * Test that YouTube embed falls back to link when thumbnail extraction fails
+ */
+ public function test_youtube_embed_falls_back_to_link_when_thumbnail_fails(): void {
+ $parsed_youtube_invalid = array(
+ 'blockName' => 'core/embed',
+ 'attrs' => array(
+ 'url' => 'https://www.youtube.com/watch?v=invalid',
+ 'type' => 'video',
+ 'providerNameSlug' => 'youtube',
+ 'responsive' => true,
+ ),
+ 'innerHTML' => '<figure class="wp-block-embed is-type-video is-provider-youtube wp-block-embed-youtube"><div class="wp-block-embed__wrapper">https://www.youtube.com/watch?v=invalid</div></figure>',
+ );
+
+ $rendered = $this->embed_renderer->render( $parsed_youtube_invalid['innerHTML'], $parsed_youtube_invalid, $this->rendering_context );
+
+ // Should still render as video block even with invalid video ID (the thumbnail URL will be generated).
+ $this->assertStringContainsString( 'https://img.youtube.com/vi/invalid/0.jpg', $rendered );
+ $this->assertStringContainsString( 'play2x.png', $rendered );
+ }
}
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Video_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Video_Test.php
new file mode 100644
index 0000000000..1094f97dba
--- /dev/null
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Video_Test.php
@@ -0,0 +1,236 @@
+<?php
+/**
+ * This file is part of the WooCommerce Email Editor package
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare( strict_types = 1 );
+namespace Automattic\WooCommerce\EmailEditor\Tests\Integration\Integrations\Core\Renderer\Blocks;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Email_Editor;
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
+use Automattic\WooCommerce\EmailEditor\Engine\Theme_Controller;
+use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Video;
+
+/**
+ * Integration test for Video class
+ */
+class Video_Test extends \Email_Editor_Integration_Test_Case {
+ /**
+ * Video renderer instance
+ *
+ * @var Video
+ */
+ private $video_renderer;
+
+ /**
+ * Basic video block configuration
+ *
+ * @var array
+ */
+ private $parsed_video = array(
+ 'blockName' => 'core/video',
+ 'attrs' => array(
+ 'poster' => 'https://example.com/poster.jpg',
+ 'title' => 'Sample Video',
+ ),
+ 'innerHTML' => '<figure class="wp-block-video"><video controls poster="https://example.com/poster.jpg"><source src="https://example.com/video.mp4" type="video/mp4" /></video></figure>',
+ );
+
+ /**
+ * Video block with different poster URL for variety
+ *
+ * @var array
+ */
+ private $parsed_video_alt = array(
+ 'blockName' => 'core/video',
+ 'attrs' => array(
+ 'poster' => 'https://example.com/another-poster.jpg',
+ 'title' => 'Another Video',
+ ),
+ 'innerHTML' => '<figure class="wp-block-video"><video controls poster="https://example.com/another-poster.jpg"><source src="https://example.com/video2.mp4" type="video/mp4" /></video></figure>',
+ );
+
+ /**
+ * Rendering context instance.
+ *
+ * @var Rendering_Context
+ */
+ private $rendering_context;
+
+ /**
+ * Set up before each test
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->di_container->get( Email_Editor::class )->initialize();
+ $this->video_renderer = new Video();
+ $theme_controller = $this->di_container->get( Theme_Controller::class );
+ $this->rendering_context = new Rendering_Context( $theme_controller->get_theme() );
+ }
+
+ /**
+ * Test it renders video content with poster and play button
+ */
+ public function testItRendersVideoContentWithPosterAndPlayButton(): void {
+ $rendered = $this->video_renderer->render( '', $this->parsed_video, $this->rendering_context );
+
+ // Should contain cover block elements (since we're reusing cover renderer).
+ $this->assertStringContainsString( 'email-block-cover', $rendered );
+
+ // Should contain the poster image as background.
+ $this->assertStringContainsString( 'background-image:url("https://example.com/poster.jpg")', $rendered );
+
+ // Should contain play button.
+ $this->assertStringContainsString( 'play2x.png', $rendered );
+ $this->assertStringContainsString( 'alt="Play"', $rendered );
+ }
+
+ /**
+ * Test it extracts poster URL from attributes
+ */
+ public function testItExtractsPosterUrlFromAttributes(): void {
+ $rendered = $this->video_renderer->render( '', $this->parsed_video, $this->rendering_context );
+ $this->assertStringContainsString( 'background-image:url("https://example.com/poster.jpg")', $rendered );
+ }
+
+ /**
+ * Test it works with different poster URLs
+ */
+ public function testItWorksWithDifferentPosterUrls(): void {
+ $rendered = $this->video_renderer->render( '', $this->parsed_video_alt, $this->rendering_context );
+ $this->assertStringContainsString( 'background-image:url("https://example.com/another-poster.jpg")', $rendered );
+ $this->assertStringContainsString( 'email-block-cover', $rendered );
+ }
+
+ /**
+ * Test it extracts video title from attributes
+ */
+ public function testItExtractsVideoTitleFromAttributes(): void {
+ $rendered = $this->video_renderer->render( '', $this->parsed_video, $this->rendering_context );
+
+ // The title should be used as alt text for the background image.
+ // Note: The cover renderer handles this, so we check for table structure.
+ $this->assertStringContainsString( 'email-block-cover', $rendered );
+ }
+
+ /**
+ * Test it handles video without poster (returns empty)
+ */
+ public function testItHandlesVideoWithoutPoster(): void {
+ $parsed_video = $this->parsed_video;
+ $parsed_video['attrs'] = array();
+ $parsed_video['innerHTML'] = '<figure class="wp-block-video"><video controls><source src="https://example.com/video.mp4" type="video/mp4" /></video></figure>';
+
+ $rendered = $this->video_renderer->render( '', $parsed_video, $this->rendering_context );
+
+ // Should return empty content when no poster is available.
+ $this->assertStringNotContainsString( 'email-block-cover', $rendered );
+ }
+
+ /**
+ * Test it provides default title when none specified
+ */
+ public function testItProvidesDefaultTitleWhenNoneSpecified(): void {
+ $parsed_video = $this->parsed_video;
+ $parsed_video['attrs'] = array(
+ 'poster' => 'https://example.com/poster.jpg',
+ );
+
+ $rendered = $this->video_renderer->render( '', $parsed_video, $this->rendering_context );
+
+ // Should still render successfully with default title.
+ $this->assertStringContainsString( 'email-block-cover', $rendered );
+ $this->assertStringContainsString( 'background-image', $rendered );
+ }
+
+ /**
+ * Test it creates play button with correct styling
+ */
+ public function testItCreatesPlayButtonWithCorrectStyling(): void {
+ $rendered = $this->video_renderer->render( '', $this->parsed_video, $this->rendering_context );
+
+ // Should contain play button with proper styling.
+ $this->assertStringContainsString( 'width: 48px; height: 48px', $rendered );
+ $this->assertStringContainsString( 'display: inline-block', $rendered );
+ $this->assertStringContainsString( 'text-align: center', $rendered );
+ }
+
+ /**
+ * Test it handles different video formats correctly
+ */
+ public function testItHandlesDifferentVideoFormatsCorrectly(): void {
+ $rendered = $this->video_renderer->render( '', $this->parsed_video_alt, $this->rendering_context );
+
+ // Should render properly for any video with poster.
+ $this->assertStringContainsString( 'email-block-cover', $rendered );
+ $this->assertStringContainsString( 'background-image', $rendered );
+ $this->assertStringContainsString( 'play2x.png', $rendered );
+ }
+
+ /**
+ * Test it renders with custom video HTML structure
+ */
+ public function testItRendersWithCustomVideoHtmlStructure(): void {
+ $parsed_video = $this->parsed_video;
+ $parsed_video['innerHTML'] = '<figure class="wp-block-video custom-class another-class"><video controls poster="https://example.com/poster.jpg"></video></figure>';
+
+ $rendered = $this->video_renderer->render( '', $parsed_video, $this->rendering_context );
+
+ // Should render successfully regardless of HTML structure (classes don't matter in email).
+ $this->assertStringContainsString( 'email-block-cover', $rendered );
+ $this->assertStringContainsString( 'background-image', $rendered );
+ $this->assertStringContainsString( 'play2x.png', $rendered );
+ }
+
+ /**
+ * Test it creates email-compatible table structure
+ */
+ public function testItCreatesEmailCompatibleTableStructure(): void {
+ $rendered = $this->video_renderer->render( '', $this->parsed_video, $this->rendering_context );
+
+ // Should use table-based layout for email compatibility.
+ $this->assertStringContainsString( '<table', $rendered );
+ $this->assertStringContainsString( 'width="100%"', $rendered );
+ $this->assertStringContainsString( 'border-collapse', $rendered );
+ }
+
+ /**
+ * Test it returns empty when no poster available
+ */
+ public function testItReturnsEmptyWhenNoPosterAvailable(): void {
+ $parsed_video = $this->parsed_video;
+ $parsed_video['attrs'] = array(); // Remove poster from attrs.
+ $parsed_video['innerHTML'] = '<figure class="wp-block-video"><video controls><source src="https://example.com/video.mp4" type="video/mp4" /></video></figure>';
+
+ $rendered = $this->video_renderer->render( '', $parsed_video, $this->rendering_context );
+
+ // Should return empty content when no poster is available.
+ $this->assertStringNotContainsString( 'email-block-cover', $rendered );
+ $this->assertStringNotContainsString( 'background-image', $rendered );
+ }
+
+ /**
+ * Test it delegates to cover renderer properly
+ */
+ public function testItDelegatesToCoverRendererProperly(): void {
+ $rendered = $this->video_renderer->render( '', $this->parsed_video, $this->rendering_context );
+
+ // Should have all the characteristics of a cover block.
+ $this->assertStringContainsString( 'email-block-cover', $rendered );
+ $this->assertStringContainsString( 'background-size:cover', $rendered );
+ $this->assertStringContainsString( 'background-position:center', $rendered );
+ $this->assertStringContainsString( 'background-repeat:no-repeat', $rendered );
+ }
+
+ /**
+ * Test it applies minimum height to video blocks
+ */
+ public function testItAppliesMinimumHeightToVideoBlocks(): void {
+ $rendered = $this->video_renderer->render( '', $this->parsed_video, $this->rendering_context );
+
+ // Should apply minimum height for consistent video block appearance.
+ $this->assertStringContainsString( 'min-height:390px', $rendered );
+ }
+}