Commit 8a63d65fbd for woocommerce
commit 8a63d65fbd00cbf0c2b206d52da8ebf6929be4df
Author: Allison Levine <1689238+allilevine@users.noreply.github.com>
Date: Tue Sep 16 16:08:02 2025 -0400
Email Editor: Add email rendering instructions for cover block (#60837)
* Email Editor: Add email rendering instructions for cover block.
* Update and use color validation from helper.
* Address coderabbit feedback.
* Restore background image functionality.
* Improve background image extraction fallback.
* Clean up now unused code.
* Remove extra padding.
* Fix linting issue.
* Use HTML API for background image fallback.
* Get min height and default to 430px.
* Fix HSLA validation and test.
diff --git a/packages/php/email-editor/changelog/add-cover-block-email-rendering b/packages/php/email-editor/changelog/add-cover-block-email-rendering
new file mode 100644
index 0000000000..bd000cc92d
--- /dev/null
+++ b/packages/php/email-editor/changelog/add-cover-block-email-rendering
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add email rendering instructions for the core/cover block.
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-cover.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-cover.php
new file mode 100644
index 0000000000..90fb9f0052
--- /dev/null
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-cover.php
@@ -0,0 +1,252 @@
+<?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\Table_Wrapper_Helper;
+use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
+use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
+use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Html_Processing_Helper;
+
+/**
+ * Cover block renderer.
+ * This renderer handles core/cover blocks with proper email-friendly HTML layout.
+ */
+class Cover extends Abstract_Block_Renderer {
+ /**
+ * Renders the cover block content using a table-based layout for email compatibility.
+ *
+ * @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();
+ $inner_blocks = $parsed_block['innerBlocks'] ?? array();
+
+ // Render all inner blocks content.
+ $inner_content = '';
+ foreach ( $inner_blocks as $block ) {
+ $inner_content .= render_block( $block );
+ }
+
+ // If we don't have inner content, return empty.
+ if ( empty( $inner_content ) ) {
+ return '';
+ }
+
+ // Build the email-friendly layout.
+ $background_image = $this->extract_background_image( $block_attrs, $parsed_block['innerHTML'] ?? $block_content );
+ return $this->build_email_layout( $inner_content, $block_attrs, $block_content, $background_image, $rendering_context );
+ }
+
+ /**
+ * Build the email-friendly layout for cover blocks.
+ *
+ * @param string $inner_content Inner content.
+ * @param array $block_attrs Block attributes.
+ * @param string $block_content Original block content.
+ * @param string $background_image Background image URL.
+ * @param Rendering_Context $rendering_context Rendering context.
+ * @return string Rendered HTML.
+ */
+ private function build_email_layout( string $inner_content, array $block_attrs, string $block_content, string $background_image, Rendering_Context $rendering_context ): string {
+ // Get original wrapper classes from block content.
+ $original_wrapper_classname = ( new Dom_Document_Helper( $block_content ) )->get_attribute_value_by_tag_name( 'div', 'class' ) ?? '';
+
+ // Get background color information.
+ $background_color = $this->get_background_color( $block_attrs, $rendering_context );
+
+ // Get block styles using the Styles_Helper.
+ $block_styles = Styles_Helper::get_block_styles( $block_attrs, $rendering_context, array( 'padding', 'border', 'background-color' ) );
+ $default_styles = array(
+ 'width' => '100%',
+ 'border-collapse' => 'collapse',
+ 'text-align' => 'center',
+ );
+
+ // Add minimum height (use specified value or default).
+ $min_height = $this->get_minimum_height( $block_attrs );
+ $default_styles['min-height'] = ! empty( $min_height ) ? $min_height : '430px';
+
+ $block_styles = Styles_Helper::extend_block_styles(
+ $block_styles,
+ $default_styles
+ );
+
+ // Add background image to table styles if present.
+ if ( ! empty( $background_image ) ) {
+ $block_styles = Styles_Helper::extend_block_styles(
+ $block_styles,
+ array(
+ 'background-image' => 'url("' . esc_url( $background_image ) . '")',
+ 'background-size' => 'cover',
+ 'background-position' => 'center',
+ 'background-repeat' => 'no-repeat',
+ )
+ );
+ } elseif ( ! empty( $background_color ) ) {
+ // If no background image but there's a background color, use it.
+ $block_styles = Styles_Helper::extend_block_styles(
+ $block_styles,
+ array(
+ 'background-color' => $background_color,
+ )
+ );
+ }
+
+ // Apply class and style attributes to the wrapper table.
+ $table_attrs = array(
+ 'class' => 'email-block-cover ' . esc_attr( $original_wrapper_classname ),
+ 'style' => $block_styles['css'],
+ 'align' => 'center',
+ 'width' => '100%',
+ );
+
+ // Build the cover content without background (background is now on the table).
+ $cover_content = $this->build_cover_content( $inner_content );
+
+ // Build individual table cell.
+ $cell_attrs = array(
+ 'valign' => 'middle',
+ 'align' => 'center',
+ );
+
+ $cell = Table_Wrapper_Helper::render_table_cell( $cover_content, $cell_attrs );
+
+ // Use render_cell = false to avoid wrapping in an extra <td>.
+ return Table_Wrapper_Helper::render_table_wrapper( $cell, $table_attrs, array(), array(), false );
+ }
+
+ /**
+ * Extract background image from block attributes or HTML content.
+ *
+ * @param array $block_attrs Block attributes.
+ * @param string $block_content Original block content.
+ * @return string Background image URL or empty string.
+ */
+ private function extract_background_image( array $block_attrs, string $block_content ): string {
+ // First check block attributes for URL.
+ if ( ! empty( $block_attrs['url'] ) ) {
+ return esc_url( $block_attrs['url'] );
+ }
+
+ // Fallback: use HTML API to find background image src.
+ $html = new \WP_HTML_Tag_Processor( $block_content );
+
+ while ( $html->next_tag( array( 'tag_name' => 'img' ) ) ) {
+ $class_attr = $html->get_attribute( 'class' );
+ // Check if this img tag has the wp-block-cover__image-background class.
+ if ( is_string( $class_attr ) && false !== strpos( $class_attr, 'wp-block-cover__image-background' ) ) {
+ $src = $html->get_attribute( 'src' );
+ if ( is_string( $src ) ) {
+ return esc_url( $src );
+ }
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * Get minimum height from block attributes.
+ *
+ * @param array $block_attrs Block attributes.
+ * @return string Minimum height value or empty string.
+ */
+ private function get_minimum_height( array $block_attrs ): string {
+ // Check for minHeight attribute (legacy format).
+ if ( ! empty( $block_attrs['minHeight'] ) ) {
+ return Html_Processing_Helper::sanitize_dimension_value( $block_attrs['minHeight'] );
+ }
+
+ // Check for style.dimensions.minHeight (WordPress 6.2+ format).
+ if ( ! empty( $block_attrs['style']['dimensions']['minHeight'] ) ) {
+ return Html_Processing_Helper::sanitize_dimension_value( $block_attrs['style']['dimensions']['minHeight'] );
+ }
+
+ return '';
+ }
+
+ /**
+ * Get background color from block attributes.
+ *
+ * @param array $block_attrs Block attributes.
+ * @param Rendering_Context $rendering_context Rendering context.
+ * @return string Background color or empty string.
+ */
+ private function get_background_color( array $block_attrs, Rendering_Context $rendering_context ): string {
+ // Check for custom overlay color first (used as background color when no image).
+ if ( ! empty( $block_attrs['customOverlayColor'] ) ) {
+ $color = $block_attrs['customOverlayColor'];
+ $sanitized_color = $this->validate_and_sanitize_color( $color );
+ if ( ! empty( $sanitized_color ) ) {
+ return $sanitized_color;
+ }
+ }
+
+ // Check for overlay color slug (used as background color when no image).
+ if ( ! empty( $block_attrs['overlayColor'] ) ) {
+ $translated_color = $rendering_context->translate_slug_to_color( $block_attrs['overlayColor'] );
+ $sanitized_color = $this->validate_and_sanitize_color( $translated_color );
+ if ( ! empty( $sanitized_color ) ) {
+ return $sanitized_color;
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * Validate and sanitize a color value, returning empty string for invalid colors.
+ *
+ * @param string $color The color value to validate and sanitize.
+ * @return string Sanitized color or empty string if invalid.
+ */
+ private function validate_and_sanitize_color( string $color ): string {
+ $sanitized_color = Html_Processing_Helper::sanitize_color( $color );
+
+ // If sanitize_color returned the default fallback, check if the original was actually valid.
+ if ( '#000000' === $sanitized_color && '#000000' !== $color ) {
+ // The original color was invalid, so return empty string.
+ return '';
+ }
+
+ // The color is valid (either it was sanitized to something other than the default,
+ // or it was specifically #000000 which is a valid color).
+ return $sanitized_color;
+ }
+
+ /**
+ * Build the cover content with background image or color.
+ *
+ * @param string $inner_content Inner content.
+ * @return string Cover content HTML.
+ */
+ private function build_cover_content( string $inner_content ): string {
+ $cover_style = 'position: relative; display: inline-block; width: 100%; max-width: 100%;';
+
+ // Wrap inner content with padding.
+ // Note: $inner_content is already rendered HTML from other blocks via render_block(),
+ // so it should be properly escaped by the individual block renderers.
+ $inner_wrapper_style = 'padding: 20px;';
+ $inner_wrapper_html = sprintf(
+ '<div class="wp-block-cover__inner-container" style="%s">%s</div>',
+ $inner_wrapper_style,
+ $inner_content
+ );
+
+ return sprintf(
+ '<div class="wp-block-cover" style="%s">%s</div>',
+ $cover_style,
+ $inner_wrapper_html
+ );
+ }
+}
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 fc6a6ab495..42117ebd44 100644
--- a/packages/php/email-editor/src/Integrations/Core/class-initializer.php
+++ b/packages/php/email-editor/src/Integrations/Core/class-initializer.php
@@ -24,6 +24,7 @@ use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Image;
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\List_Block;
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\List_Item;
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\Social_Link;
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Social_Links;
@@ -70,6 +71,7 @@ class Initializer {
'core/media-text',
'core/audio',
'core/embed',
+ 'core/cover',
);
/**
@@ -228,6 +230,9 @@ class Initializer {
case 'core/embed':
$renderer = new Embed();
break;
+ case 'core/cover':
+ $renderer = new Cover();
+ break;
default:
$renderer = new Fallback();
break;
diff --git a/packages/php/email-editor/src/Integrations/Utils/class-html-processing-helper.php b/packages/php/email-editor/src/Integrations/Utils/class-html-processing-helper.php
index 15381d21bc..e650b3dc7a 100644
--- a/packages/php/email-editor/src/Integrations/Utils/class-html-processing-helper.php
+++ b/packages/php/email-editor/src/Integrations/Utils/class-html-processing-helper.php
@@ -96,20 +96,70 @@ class Html_Processing_Helper {
}
/**
- * Sanitize color value to ensure it's a valid hex color or CSS variable.
+ * Sanitize dimension value to ensure it's a valid CSS dimension.
+ *
+ * Supports numeric values (converted to px) and standard CSS units.
+ *
+ * @param mixed $value The dimension value to sanitize.
+ * @return string Sanitized dimension value or empty string if invalid.
+ */
+ public static function sanitize_dimension_value( $value ): string {
+ if ( ! is_string( $value ) && ! is_numeric( $value ) ) {
+ return '';
+ }
+
+ $value = (string) $value;
+
+ // If it's just a number, assume pixels.
+ if ( is_numeric( $value ) ) {
+ $value = $value . 'px';
+ }
+
+ // Use existing CSS value sanitization for security.
+ $sanitized_value = self::sanitize_css_value( $value );
+
+ // Additional validation for dimension-specific units.
+ if ( ! empty( $sanitized_value ) && preg_match( '/^(\d+(?:\.\d+)?)(px|em|rem|%|vh|vw|ex|ch|in|cm|mm|pt|pc)$/', $sanitized_value ) ) {
+ return $sanitized_value;
+ }
+
+ return '';
+ }
+
+ /**
+ * Sanitize color value to ensure it's a valid color format.
+ *
+ * Supports hex colors, rgb/rgba, hsl/hsla, named colors, and CSS variables.
*
* @param string $color The color value to sanitize.
- * @return string Sanitized color value.
+ * @return string Sanitized color value or safe default if invalid.
*/
public static function sanitize_color( string $color ): string {
- // Remove any whitespace and convert to lowercase.
- $color = strtolower( trim( $color ) );
+ // Remove any whitespace.
+ $color = trim( $color );
+
+ // Check if it's a valid hex color (#fff, #ffffff, #ffffffff).
+ if ( preg_match( '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $color ) ) {
+ return strtolower( $color );
+ }
- // Check if it's a valid hex color (3 or 6 characters).
- if ( preg_match( '/^#([a-f0-9]{3}){1,2}$/i', $color ) ) {
+ // Check for rgb/rgba colors.
+ if ( preg_match( '/^rgba?\(\s*(25[0-5]|2[0-4]\d|1\d{2}|\d{1,2})\s*,\s*(25[0-5]|2[0-4]\d|1\d{2}|\d{1,2})\s*,\s*(25[0-5]|2[0-4]\d|1\d{2}|\d{1,2})\s*(?:,\s*(?:1(?:\.0+)?|0(?:\.\d+)?|\.\d+)\s*)?\)$/', $color ) ) {
return $color;
}
+ // Check for hsl/hsla colors.
+ if ( preg_match( '/^hsla?\(\s*(360|3[0-5]\d|[12]\d{2}|\d{1,2})\s*,\s*(100|[1-9]?\d)%\s*,\s*(100|[1-9]?\d)%\s*(?:,\s*(?:1(?:\.0+)?|0(?:\.\d+)?|\.\d+)\s*)?\)$/', $color ) ) {
+ return $color;
+ }
+
+ // Check for named colors and other valid CSS color values.
+ // We use a permissive approach: accept any string that doesn't contain dangerous characters
+ // and let the CSS engine handle the actual validation.
+ if ( preg_match( '/^[a-zA-Z][a-zA-Z0-9-]*$/', $color ) && ! preg_match( '/^(expression|javascript|vbscript|data|import|behavior|binding|filter|progid)/i', $color ) ) {
+ return strtolower( $color );
+ }
+
// Check if it's a CSS variable (var(--variable-name)).
if ( preg_match( '/^var\(--[a-zA-Z0-9\-_]+\)$/', $color ) ) {
return $color;
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Cover_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Cover_Test.php
new file mode 100644
index 0000000000..15ea44c5ed
--- /dev/null
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Cover_Test.php
@@ -0,0 +1,216 @@
+<?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\Cover;
+
+/**
+ * Integration test for Cover class
+ */
+class Cover_Test extends \Email_Editor_Integration_Test_Case {
+ /**
+ * Cover renderer instance
+ *
+ * @var Cover
+ */
+ private $cover_renderer;
+
+ /**
+ * Cover block configuration
+ *
+ * @var array
+ */
+ private $parsed_cover = array(
+ 'blockName' => 'core/cover',
+ 'attrs' => array(
+ 'overlayColor' => 'pale-pink',
+ ),
+ 'innerHTML' => '<div class="wp-block-cover is-light"><span aria-hidden="true" class="wp-block-cover__background has-pale-pink-background-color has-background-dim-100 has-background-dim"></span><div class="wp-block-cover__inner-container"><p class="has-text-align-center has-large-font-size">Cover block</p></div></div>',
+ 'innerBlocks' => array(
+ 0 => array(
+ 'blockName' => 'core/paragraph',
+ 'attrs' => array(
+ 'align' => 'center',
+ 'fontSize' => 'large',
+ ),
+ 'innerBlocks' => array(),
+ 'innerHTML' => '<p class="has-text-align-center has-large-font-size">Cover block</p>',
+ 'innerContent' => array(
+ 0 => '<p class="has-text-align-center has-large-font-size">Cover block</p>',
+ ),
+ ),
+ ),
+ );
+
+ /**
+ * 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->cover_renderer = new Cover();
+ $theme_controller = $this->di_container->get( Theme_Controller::class );
+ $this->rendering_context = new Rendering_Context( $theme_controller->get_theme() );
+ }
+
+ /**
+ * Test it renders cover content
+ */
+ public function testItRendersCoverContent(): void {
+ $rendered = $this->cover_renderer->render( '', $this->parsed_cover, $this->rendering_context );
+ $this->assertStringContainsString( 'Cover block', $rendered );
+ $this->assertStringContainsString( 'wp-block-cover', $rendered );
+ $this->assertStringContainsString( 'wp-block-cover__inner-container', $rendered );
+ }
+
+ /**
+ * Test it handles background image
+ */
+ public function testItHandlesBackgroundImage(): void {
+ $parsed_cover = $this->parsed_cover;
+ $parsed_cover['attrs']['url'] = 'https://example.com/background.jpg';
+
+ $rendered = $this->cover_renderer->render( '', $parsed_cover, $this->rendering_context );
+ $this->assertStringContainsString( 'background-image:url("https://example.com/background.jpg")', $rendered );
+ $this->assertStringContainsString( 'background-size:cover', $rendered );
+ $this->assertStringContainsString( 'background-position:center', $rendered );
+ }
+
+ /**
+ * Test it handles custom background color
+ */
+ public function testItHandlesCustomBackgroundColor(): void {
+ $parsed_cover = $this->parsed_cover;
+ $parsed_cover['attrs']['customOverlayColor'] = '#4b74b2';
+
+ $rendered = $this->cover_renderer->render( '', $parsed_cover, $this->rendering_context );
+ // Should apply background color directly to the cover div when no background image.
+ $this->assertStringContainsString( 'background-color:#4b74b2', $rendered );
+ }
+
+ /**
+ * Test it handles background color slug
+ */
+ public function testItHandlesBackgroundColorSlug(): void {
+ $parsed_cover = $this->parsed_cover;
+ $parsed_cover['attrs']['overlayColor'] = 'pale-pink';
+
+ $rendered = $this->cover_renderer->render( '', $parsed_cover, $this->rendering_context );
+ // Should apply background color directly to the cover div when no background image.
+ $this->assertStringContainsString( 'background-color:', $rendered );
+ }
+
+ /**
+ * Test it handles custom styling
+ */
+ public function testItHandlesCustomStyling(): void {
+ $parsed_cover = $this->parsed_cover;
+ $parsed_cover['attrs']['style'] = array(
+ 'border' => array(
+ 'color' => '#123456',
+ 'radius' => '10px',
+ 'width' => '2px',
+ 'style' => 'solid',
+ ),
+ 'color' => array(
+ 'background' => '#abcdef',
+ ),
+ 'spacing' => array(
+ 'padding' => array(
+ 'bottom' => '5px',
+ 'left' => '15px',
+ 'right' => '20px',
+ 'top' => '10px',
+ ),
+ ),
+ );
+
+ $rendered = $this->cover_renderer->render( '', $parsed_cover, $this->rendering_context );
+ $this->assertStringContainsString( 'background-color:#f78da7', $rendered );
+ $this->assertStringContainsString( 'border-color:#123456;', $rendered );
+ $this->assertStringContainsString( 'border-radius:10px;', $rendered );
+ $this->assertStringContainsString( 'border-width:2px;', $rendered );
+ $this->assertStringContainsString( 'border-style:solid;', $rendered );
+ $this->assertStringContainsString( 'padding-bottom:5px;', $rendered );
+ $this->assertStringContainsString( 'padding-left:15px;', $rendered );
+ $this->assertStringContainsString( 'padding-right:20px;', $rendered );
+ $this->assertStringContainsString( 'padding-top:10px;', $rendered );
+ }
+
+ /**
+ * Test it preserves classes set by editor
+ */
+ public function testItPreservesClassesSetByEditor(): void {
+ $parsed_cover = $this->parsed_cover;
+ $content = '<div class="wp-block-cover is-light editor-class-1 another-class"></div>';
+
+ $rendered = $this->cover_renderer->render( $content, $parsed_cover, $this->rendering_context );
+ $this->assertStringContainsString( 'wp-block-cover is-light editor-class-1 another-class', $rendered );
+ }
+
+
+ /**
+ * Test it returns empty when no inner content
+ */
+ public function testItReturnsEmptyWhenNoInnerContent(): void {
+ $parsed_cover = $this->parsed_cover;
+ $parsed_cover['innerBlocks'] = array();
+
+ $rendered = $this->cover_renderer->render( '', $parsed_cover, $this->rendering_context );
+ // The abstract block renderer adds spacers even for empty content, so we check for empty inner content.
+ $this->assertStringNotContainsString( 'wp-block-cover__inner-container', $rendered );
+ }
+
+ /**
+ * Test it extracts background image from HTML content
+ */
+ public function testItExtractsBackgroundImageFromHtml(): void {
+ $parsed_cover = $this->parsed_cover;
+ $parsed_cover['innerHTML'] = '<div class="wp-block-cover"><img class="wp-block-cover__image-background wp-image-537 size-full" alt="" src="https://example.com/background.jpg" data-object-fit="cover"/><span aria-hidden="true" class="wp-block-cover__background has-background-dim" style="background-color:#4b74b2"></span><div class="wp-block-cover__inner-container"><p>Cover block</p></div></div>';
+
+ $rendered = $this->cover_renderer->render( '', $parsed_cover, $this->rendering_context );
+ $this->assertStringContainsString( 'background-image:url("https://example.com/background.jpg")', $rendered );
+ }
+
+ /**
+ * Test it extracts background image from URL attribute
+ */
+ public function testItExtractsBackgroundImageFromUrlAttribute(): void {
+ $parsed_cover = $this->parsed_cover;
+ $parsed_cover['attrs']['url'] = 'https://example.com/background.jpg';
+
+ $rendered = $this->cover_renderer->render( '', $parsed_cover, $this->rendering_context );
+ $this->assertStringContainsString( 'background-image:url("https://example.com/background.jpg")', $rendered );
+ }
+
+ /**
+ * Test it handles background image without overlay (email compatibility)
+ */
+ public function testItHandlesBackgroundImageWithoutOverlay(): void {
+ $parsed_cover = $this->parsed_cover;
+ $parsed_cover['attrs']['url'] = 'https://example.com/background.jpg';
+ $parsed_cover['attrs']['customOverlayColor'] = '#4b74b2';
+
+ $rendered = $this->cover_renderer->render( '', $parsed_cover, $this->rendering_context );
+ $this->assertStringContainsString( 'background-image:url("https://example.com/background.jpg")', $rendered );
+ // Should not have overlay elements for email compatibility.
+ $this->assertStringNotContainsString( 'wp-block-cover__background', $rendered );
+ $this->assertStringNotContainsString( 'background-color:#4b74b2', $rendered );
+ }
+}
diff --git a/packages/php/email-editor/tests/unit/Integrations/Utils/Html_Processing_Helper_Test.php b/packages/php/email-editor/tests/unit/Integrations/Utils/Html_Processing_Helper_Test.php
index f158f1621e..0e22d2095b 100644
--- a/packages/php/email-editor/tests/unit/Integrations/Utils/Html_Processing_Helper_Test.php
+++ b/packages/php/email-editor/tests/unit/Integrations/Utils/Html_Processing_Helper_Test.php
@@ -143,6 +143,7 @@ class Html_Processing_Helper_Test extends \Email_Editor_Unit_Test {
$valid_hex_colors = array(
'#fff',
'#ffffff',
+ '#ffffffff',
'#000',
'#000000',
'#abc',
@@ -173,16 +174,121 @@ class Html_Processing_Helper_Test extends \Email_Editor_Unit_Test {
}
}
+ /**
+ * Test sanitize_color with rgb/rgba colors.
+ */
+ public function testSanitizeColorWithRgbRgbaColors(): void {
+ $rgb_colors = array(
+ 'rgb(255, 255, 255)',
+ 'rgb(0, 0, 0)',
+ 'rgb(128, 128, 128)',
+ 'rgba(255, 255, 255, 0.5)',
+ 'rgba(0, 0, 0, 1)',
+ 'rgba(128, 128, 128, 0.8)',
+ );
+
+ foreach ( $rgb_colors as $color ) {
+ $result = Html_Processing_Helper::sanitize_color( $color );
+ $this->assertEquals( $color, $result, "Failed to preserve rgb/rgba color: $color" );
+ }
+ }
+
+ /**
+ * Test sanitize_color with hsl/hsla colors.
+ */
+ public function testSanitizeColorWithHslHslaColors(): void {
+ $hsl_colors = array(
+ 'hsl(0, 0%, 100%)',
+ 'hsl(0, 0%, 0%)',
+ 'hsl(120, 50%, 50%)',
+ 'hsla(0, 0%, 100%, 0.5)',
+ 'hsla(0, 0%, 0%, 1)',
+ 'hsla(120, 50%, 50%, 0.8)',
+ );
+
+ foreach ( $hsl_colors as $color ) {
+ $result = Html_Processing_Helper::sanitize_color( $color );
+ $this->assertEquals( $color, $result, "Failed to preserve hsl/hsla color: $color" );
+ }
+ }
+
+ /**
+ * Test sanitize_color with named colors and CSS keywords.
+ *
+ * Uses a permissive approach that accepts any string that looks like a valid CSS color name.
+ * This test demonstrates that the method accepts various named colors without maintaining
+ * a hardcoded list, making it future-proof for new CSS named colors.
+ */
+ public function testSanitizeColorWithNamedColorsAndKeywords(): void {
+ $valid_colors = array(
+ // Standard named colors.
+ 'black',
+ 'white',
+ 'red',
+ 'green',
+ 'blue',
+ 'yellow',
+ 'orange',
+ 'purple',
+ 'pink',
+ 'brown',
+ 'gray',
+ 'grey',
+ 'navy',
+ 'lime',
+ 'teal',
+ 'crimson',
+ 'aqua',
+ 'fuchsia',
+ 'silver',
+ 'maroon',
+ 'olive',
+ 'gold',
+ // CSS keywords.
+ 'transparent',
+ 'inherit',
+ 'initial',
+ 'unset',
+ // Future CSS named colors (will work automatically).
+ 'rebeccapurple',
+ 'lightcoral',
+ 'mediumseagreen',
+ // Additional named colors that would work with the permissive approach.
+ 'lightblue',
+ 'darkgreen',
+ 'hotpink',
+ 'forestgreen',
+ 'royalblue',
+ 'orangered',
+ 'deepskyblue',
+ 'mediumvioletred',
+ 'lightsteelblue',
+ 'palegoldenrod',
+ // Made-up but valid-looking color names (demonstrates permissiveness).
+ 'customcolor',
+ 'brandprimary',
+ 'accentcolor',
+ );
+
+ foreach ( $valid_colors as $color ) {
+ $result = Html_Processing_Helper::sanitize_color( $color );
+ $this->assertEquals( strtolower( $color ), $result, "Failed to preserve named color: $color" );
+ }
+ }
+
/**
* Test sanitize_color with invalid colors returns default.
*/
public function testSanitizeColorWithInvalidColorsReturnsDefault(): void {
$invalid_colors = array(
- 'invalid',
'#ggg',
'#gggggg',
'rgb(256, 256, 256)',
+ 'rgba(255, 255, 255, 2.0)', // Invalid alpha > 1.
+ 'hsl(400, 50%, 50%)', // Invalid hue > 360.
+ 'hsla(120, 150%, 50%, 0.5)', // Invalid saturation > 100%.
'var(invalid)',
+ 'var(--)', // Invalid CSS variable.
'',
);
@@ -192,6 +298,73 @@ class Html_Processing_Helper_Test extends \Email_Editor_Unit_Test {
}
}
+ /**
+ * Test sanitize_color with dangerous color values returns default.
+ */
+ public function testSanitizeColorWithDangerousValuesReturnsDefault(): void {
+ $dangerous_colors = array(
+ 'expression(alert(1))',
+ 'javascript:alert(1)',
+ 'vbscript:alert(1)',
+ 'data:text/html,<script>alert(1)</script>',
+ 'import(url)',
+ 'behavior:url(script.htc)',
+ 'binding:url(script.xml)',
+ 'filter:progid:DXImageTransform.Microsoft.Alpha(opacity=50)',
+ );
+
+ foreach ( $dangerous_colors as $color ) {
+ $result = Html_Processing_Helper::sanitize_color( $color );
+ $this->assertEquals( '#000000', $result, "Failed to return default for dangerous color: $color" );
+ }
+ }
+
+ /**
+ * Test sanitize_color permissive approach with various valid-looking color names.
+ *
+ * This test specifically demonstrates the permissive nature of the color validation,
+ * showing that it accepts any string that looks like a valid CSS color name,
+ * regardless of whether it's a real CSS color or not.
+ */
+ public function testSanitizeColorPermissiveApproach(): void {
+ $permissive_colors = array(
+ // Real CSS named colors.
+ 'red',
+ 'blue',
+ 'green',
+ // Made-up but valid-looking color names.
+ 'brandcolor',
+ 'primaryaccent',
+ 'secondaryhighlight',
+ 'customtheme',
+ 'brandprimary',
+ 'brandsecondary',
+ 'accentcolor',
+ 'highlightcolor',
+ 'textcolor',
+ 'backgroundcolor',
+ 'linkcolor',
+ 'buttoncolor',
+ 'headercolor',
+ 'footercolor',
+ 'sidebarcolor',
+ 'contentcolor',
+ 'widgetcolor',
+ 'formcolor',
+ 'inputcolor',
+ 'labelcolor',
+ 'errorcolor',
+ 'successcolor',
+ 'warningcolor',
+ 'infocolor',
+ );
+
+ foreach ( $permissive_colors as $color ) {
+ $result = Html_Processing_Helper::sanitize_color( $color );
+ $this->assertEquals( strtolower( $color ), $result, "Failed to preserve permissive color: $color" );
+ }
+ }
+
/**
* Test sanitize_color trims whitespace.
*/
@@ -598,4 +771,134 @@ class Html_Processing_Helper_Test extends \Email_Editor_Unit_Test {
$result = Html_Processing_Helper::sanitize_image_html( $html );
$this->assertEquals( '', $result );
}
+
+ /**
+ * Test sanitize_color with valid RGBA alpha values (0-1).
+ */
+ public function testSanitizeColorWithValidRgbaAlphaValues(): void {
+ $valid_rgba_colors = array(
+ 'rgba(255, 255, 255, 0)', // Alpha 0.
+ 'rgba(255, 255, 255, 0.5)', // Alpha 0.5.
+ 'rgba(255, 255, 255, .5)', // Alpha .5 (no leading zero).
+ 'rgba(255, 255, 255, 1)', // Alpha 1.
+ 'rgba(255, 255, 255, 1.0)', // Alpha 1.0.
+ 'rgba(255, 255, 255, 1.00)', // Alpha 1.00.
+ 'rgba(255, 255, 255, 0.25)', // Alpha 0.25.
+ 'rgba(255, 255, 255, 0.75)', // Alpha 0.75.
+ 'rgba(120, 130, 140, .25)', // Alpha .25 with different RGB values.
+ );
+
+ foreach ( $valid_rgba_colors as $color ) {
+ $result = Html_Processing_Helper::sanitize_color( $color );
+ $this->assertEquals( $color, $result, "Failed to preserve valid RGBA color with alpha: $color" );
+ }
+ }
+
+ /**
+ * Test sanitize_color with invalid RGBA alpha values (>1).
+ */
+ public function testSanitizeColorWithInvalidRgbaAlphaValues(): void {
+ $invalid_rgba_colors = array(
+ 'rgba(255, 255, 255, 1.25)', // Alpha > 1.
+ 'rgba(255, 255, 255, 2)', // Alpha = 2.
+ 'rgba(255, 255, 255, 1.5)', // Alpha = 1.5.
+ 'rgba(255, 255, 255, 10)', // Alpha = 10.
+ 'rgba(255, 255, 255, 2.0)', // Alpha = 2.0.
+ 'rgba(255, 255, 255, 1.01)', // Alpha slightly > 1.
+ );
+
+ foreach ( $invalid_rgba_colors as $color ) {
+ $result = Html_Processing_Helper::sanitize_color( $color );
+ $this->assertEquals( '#000000', $result, "Failed to reject invalid RGBA color with alpha > 1: $color" );
+ }
+ }
+
+ /**
+ * Test sanitize_color with valid HSLA alpha values (0-1).
+ */
+ public function testSanitizeColorWithValidHslaAlphaValues(): void {
+ $valid_hsla_colors = array(
+ 'hsla(120, 50%, 50%, 0)', // Alpha 0.
+ 'hsla(120, 50%, 50%, 0.5)', // Alpha 0.5.
+ 'hsla(120, 50%, 50%, .5)', // Alpha .5 (no leading zero).
+ 'hsla(120, 50%, 50%, 1)', // Alpha 1.
+ 'hsla(120, 50%, 50%, 1.0)', // Alpha 1.0.
+ 'hsla(120, 50%, 50%, 1.00)', // Alpha 1.00.
+ 'hsla(240, 75%, 25%, 0.25)', // Alpha 0.25.
+ 'hsla(360, 100%, 75%, 0.9)', // Alpha 0.9.
+ 'hsla(0, 0%, 100%, .75)', // Alpha .75 with white color.
+ );
+
+ foreach ( $valid_hsla_colors as $color ) {
+ $result = Html_Processing_Helper::sanitize_color( $color );
+ $this->assertEquals( $color, $result, "Failed to preserve valid HSLA color with alpha: $color" );
+ }
+ }
+
+ /**
+ * Test sanitize_color with invalid HSLA alpha values (>1).
+ */
+ public function testSanitizeColorWithInvalidHslaAlphaValues(): void {
+ $invalid_hsla_colors = array(
+ 'hsla(120, 50%, 50%, 1.25)', // Alpha > 1.
+ 'hsla(120, 50%, 50%, 2)', // Alpha = 2.
+ 'hsla(120, 50%, 50%, 1.5)', // Alpha = 1.5.
+ 'hsla(120, 50%, 50%, 10)', // Alpha = 10.
+ 'hsla(120, 50%, 50%, 2.0)', // Alpha = 2.0.
+ 'hsla(120, 50%, 50%, 1.01)', // Alpha slightly > 1.
+ );
+
+ foreach ( $invalid_hsla_colors as $color ) {
+ $result = Html_Processing_Helper::sanitize_color( $color );
+ $this->assertEquals( '#000000', $result, "Failed to reject invalid HSLA color with alpha > 1: $color" );
+ }
+ }
+
+ /**
+ * Test sanitize_dimension_value with valid dimension values.
+ */
+ public function testSanitizeDimensionValueWithValidValues(): void {
+ $valid_dimensions = array(
+ '430' => '430px', // Numeric value gets px added.
+ '430px' => '430px', // Already has px.
+ '50%' => '50%', // Percentage.
+ '2em' => '2em', // Em units.
+ '1.5rem' => '1.5rem', // Rem units.
+ '100vh' => '100vh', // Viewport height.
+ '50vw' => '50vw', // Viewport width.
+ '12pt' => '12pt', // Points.
+ '1in' => '1in', // Inches.
+ '2.5cm' => '2.5cm', // Centimeters.
+ );
+
+ foreach ( $valid_dimensions as $input => $expected ) {
+ $result = Html_Processing_Helper::sanitize_dimension_value( $input );
+ $this->assertEquals( $expected, $result, "Failed to sanitize dimension: $input" );
+ }
+ }
+
+ /**
+ * Test sanitize_dimension_value with invalid dimension values.
+ */
+ public function testSanitizeDimensionValueWithInvalidValues(): void {
+ $invalid_dimensions = array(
+ 'invalid',
+ '<script>alert("xss")</script>',
+ 'expression(alert(1))',
+ 'javascript:alert(1)',
+ '',
+ ' ',
+ '430invalid',
+ 'px430',
+ null,
+ array(),
+ false,
+ );
+
+ foreach ( $invalid_dimensions as $input ) {
+ $result = Html_Processing_Helper::sanitize_dimension_value( $input );
+ $input_description = is_string( $input ) ? $input : gettype( $input );
+ $this->assertEquals( '', $result, 'Failed to reject invalid dimension: ' . $input_description );
+ }
+ }
}