Commit 76de9e1e526 for woocommerce
commit 76de9e1e526a8c8a76fe16b32d96fdc502d927fe
Author: Allison Levine <1689238+allilevine@users.noreply.github.com>
Date: Thu Oct 29 11:45:47 2026 -0400
Apply design caption styles to email captions (#70339)
* fix: apply design caption styles to email captions
`Theme_Controller::get_stylesheet_for_rendering()` translated only `heading`,
`link` and `button` into email selectors. Every other element name was used as
a literal CSS selector, so a caption color saved in the design became
`caption{color:…}` — a rule for the `<caption>` table tag, which a rendered
email never contains. The canvas showed the color because the block editor
uses core's `.wp-element-caption` selector.
Map `caption` to `.wp-element-caption`, and give the table and embed caption
wrappers that class so they are covered too; image and gallery captions
already carry it.
Fixes NL-1100.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: register the test's image renderer without a dynamic property
PHPStan rejects assigning `render_email_callback` straight onto `WP_Block_Type`.
Re-register `core/image` through the Core integration's settings filter instead,
which is how the callback is attached in production, and restore the original
registration afterwards so later tests still see the block core registered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: drop an inert metadata filter from the caption render test
`register_block_type( $name, $args )` goes straight to the registry;
WordPress applies `block_type_metadata_settings` only from
`register_block_type_from_metadata()`. The add/remove pair did nothing and
implied the test reproduced production's metadata path. The settings still
come from `update_block_settings()`, now shaped like the equivalent
re-registration in `Renderer_Test`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: apply caption styles to image captions saved without the class
Image captions saved before WordPress 6.2 have a bare `<figcaption>`. The
renderer rewrote it to a `<span>` keeping its attributes, so it reached the
email with no class for `.wp-element-caption` to match and the design's
caption color never applied to it.
Add the class during that rewrite, preserving any classes already there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/packages/php/email-editor/changelog/fix-nl-1100-caption-element-styles b/packages/php/email-editor/changelog/fix-nl-1100-caption-element-styles
new file mode 100644
index 00000000000..2e887665878
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-nl-1100-caption-element-styles
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Apply a caption color chosen in the email design to image, gallery, table and embed captions in the sent email.
diff --git a/packages/php/email-editor/src/Engine/class-theme-controller.php b/packages/php/email-editor/src/Engine/class-theme-controller.php
index fad87b89fc4..220d85dadf0 100644
--- a/packages/php/email-editor/src/Engine/class-theme-controller.php
+++ b/packages/php/email-editor/src/Engine/class-theme-controller.php
@@ -267,6 +267,10 @@ class Theme_Controller {
case 'link':
$selector = 'a:not(.button-link)';
break;
+ case 'caption':
+ // Captions render as elements carrying core's caption class, never as a `<caption>` tag.
+ $selector = '.wp-element-caption';
+ break;
}
$css_elements .= wp_style_engine_get_styles( $elements_style, array( 'selector' => $selector ) )['css'] ?? '';
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 76252f96045..271f72f5b71 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
@@ -250,7 +250,7 @@ class Embed extends Abstract_Block_Renderer {
return '';
}
- return '<div style="text-align: center; margin-top: 8px;">' . Html_Processing_Helper::sanitize_caption_html( $caption ) . '</div>';
+ return '<div class="wp-element-caption" style="text-align: center; margin-top: 8px;">' . Html_Processing_Helper::sanitize_caption_html( $caption ) . '</div>';
}
/**
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php
index 9f67a739eca..5a906349b30 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php
@@ -352,6 +352,35 @@ class Image extends Abstract_Block_Renderer {
return $block_content;
}
+ /**
+ * Add core's caption class to the caption element when it is missing.
+ *
+ * Image captions saved before WordPress 6.2 carry no class, and the email's caption styles key
+ * off that class, so without it a saved caption color never reaches them.
+ *
+ * @param string $caption_html Caption HTML.
+ * @return string Caption HTML carrying the caption class.
+ */
+ private function add_caption_class( string $caption_html ): string {
+ if ( '' === $caption_html ) {
+ return $caption_html;
+ }
+
+ $html = new \WP_HTML_Tag_Processor( $caption_html );
+ if ( ! $html->next_tag( array( 'tag_name' => 'SPAN' ) ) ) {
+ return $caption_html;
+ }
+
+ $existing_class = trim( (string) ( $html->get_attribute( 'class' ) ?? '' ) );
+ $classes = preg_split( '/\s+/', $existing_class, -1, PREG_SPLIT_NO_EMPTY );
+ if ( is_array( $classes ) && in_array( 'wp-element-caption', $classes, true ) ) {
+ return $caption_html;
+ }
+
+ $html->set_attribute( 'class', $existing_class ? $existing_class . ' wp-element-caption' : 'wp-element-caption' );
+ return $html->get_updated_html();
+ }
+
/**
* This method configure the font size of the caption because it's set to 0 for the parent element to avoid unexpected white spaces
* We try to use font-size passed down from the parent element $parsedBlock['email_attrs']['font-size'], but if it's not set, we use the default font-size from the email theme.
@@ -549,6 +578,7 @@ class Image extends Abstract_Block_Renderer {
$figcaption = $dom_helper->find_element( 'figcaption' );
$figcaption_html = $figcaption ? $dom_helper->get_outer_html( $figcaption ) : '';
$figcaption_html = str_replace( array( '<figcaption', '</figcaption>' ), array( '<span', '</span>' ), $figcaption_html );
+ $figcaption_html = $this->add_caption_class( $figcaption_html );
$anchor_tag = $dom_helper->find_element( 'a' );
$anchor_tag_href = $anchor_tag ? $dom_helper->get_attribute_value( $anchor_tag, 'href' ) : '';
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-table.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-table.php
index 6f596fba0bf..8932d19568d 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-table.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-table.php
@@ -127,7 +127,7 @@ class Table extends Abstract_Block_Renderer {
$sanitized_caption = Html_Processing_Helper::sanitize_caption_html( $caption );
// Extract typography styles from table styles (not spacing styles) and apply to caption.
$caption_styles = $this->extract_typography_styles_for_caption( $table_styles['css'] );
- $complete_content .= '<div style="text-align: center; margin-top: 8px; ' . $caption_styles . '">' . $sanitized_caption . '</div>';
+ $complete_content .= '<div class="wp-element-caption" style="text-align: center; margin-top: 8px; ' . $caption_styles . '">' . $sanitized_caption . '</div>';
}
$table_attrs = array(
diff --git a/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php b/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php
index efc5af993f0..398ffa8f80f 100644
--- a/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php
+++ b/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php
@@ -9,6 +9,7 @@ declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer;
use Automattic\WooCommerce\EmailEditor\Engine\Email_Editor;
+use Automattic\WooCommerce\EmailEditor\Integrations\Core\Initializer as Core_Initializer;
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Fallback;
require_once __DIR__ . '/Dummy_Block_Renderer.php';
@@ -63,6 +64,75 @@ class Content_Renderer_Test extends \Email_Editor_Integration_Test_Case {
$this->assertStringContainsString( 'Hello!', $content );
}
+ /**
+ * Test a caption color saved in the email's design reaches the rendered caption.
+ *
+ * The stylesheet holding the rule is not enough. The rule has to survive CSS inlining and land on
+ * the caption element itself.
+ */
+ public function testItAppliesASavedCaptionColorToAnImageCaption(): void {
+ $email_post_id = $this->factory->post->create(
+ array(
+ 'post_content' => '<!-- wp:image {"id":1,"sizeSlug":"full","linkDestination":"none"} -->
+<figure class="wp-block-image size-full"><img src="https://example.com/image.jpg" alt=""/><figcaption class="wp-element-caption">Caption text</figcaption></figure>
+<!-- /wp:image -->',
+ )
+ );
+ $this->assertIsInt( $email_post_id );
+ update_post_meta(
+ $email_post_id,
+ 'woocommerce_email_theme',
+ array(
+ 'styles' => array(
+ 'elements' => array(
+ 'caption' => array(
+ 'color' => array( 'text' => '#f78da7' ),
+ ),
+ ),
+ ),
+ )
+ );
+ $email_post = get_post( $email_post_id );
+ $this->assertInstanceOf( \WP_Post::class, $email_post );
+
+ $template = new \WP_Block_Template();
+ $template->id = 'template-id';
+ $template->content = '<!-- wp:post-content /-->';
+
+ // Re-register the core image block with the email renderer the Core integration gives it, the same
+ // way Renderer_Test does. Restore the original registration afterwards, since this class's other
+ // tests expect the block core registered.
+ $core_integration = $this->di_container->get( Core_Initializer::class );
+ $core_integration->initialize();
+ $registry = \WP_Block_Type_Registry::get_instance();
+ $original_block = $registry->get_registered( 'core/image' );
+ $this->assertInstanceOf( \WP_Block_Type::class, $original_block );
+
+ $registry->unregister( 'core/image' );
+ register_block_type(
+ 'core/image',
+ $core_integration->update_block_settings(
+ array(
+ 'title' => $original_block->title,
+ 'name' => $original_block->name,
+ 'category' => $original_block->category,
+ 'supports' => $original_block->supports ?? array(),
+ )
+ )
+ );
+
+ try {
+ $content = $this->renderer->render( $email_post, $template );
+ } finally {
+ $registry->unregister( 'core/image' );
+ $registry->register( $original_block );
+ }
+
+ $this->assertStringContainsString( 'Caption text', $content );
+ $this->assertSame( 1, preg_match( '/<span[^>]*class="[^"]*wp-element-caption[^"]*"[^>]*>/', $content, $caption_span ) );
+ $this->assertStringContainsString( '#f78da7', $caption_span[0] );
+ }
+
/**
* Test render() uses a synthetic post's own content and never leaks other published posts.
*/
diff --git a/packages/php/email-editor/tests/integration/Engine/Theme_Controller_Test.php b/packages/php/email-editor/tests/integration/Engine/Theme_Controller_Test.php
index e5884806d4d..059d87f3962 100644
--- a/packages/php/email-editor/tests/integration/Engine/Theme_Controller_Test.php
+++ b/packages/php/email-editor/tests/integration/Engine/Theme_Controller_Test.php
@@ -269,6 +269,41 @@ class Theme_Controller_Test extends \Email_Editor_Integration_Test_Case {
$this->assertSame( '300, 400, 500, 700', $font_weights['open-sans'] );
}
+ /**
+ * Test that a caption color saved in a design reaches the email as a rule for core's caption class.
+ *
+ * A bare `caption` selector targets the `<caption>` table tag, which the rendered email never contains.
+ */
+ public function testItMapsTheCaptionElementToTheCaptionClass(): void {
+ $add_caption_color = static function ( $theme ) {
+ $theme->merge(
+ new \WP_Theme_JSON(
+ array(
+ 'version' => 3,
+ 'styles' => array(
+ 'elements' => array(
+ 'caption' => array(
+ 'color' => array( 'text' => '#f78da7' ),
+ ),
+ ),
+ ),
+ ),
+ 'custom'
+ )
+ );
+ return $theme;
+ };
+ add_filter( 'woocommerce_email_editor_theme_json', $add_caption_color, 20 );
+
+ $stylesheet = $this->di_container->get( Theme_Controller::class )->get_stylesheet_for_rendering();
+
+ $this->assertStringContainsString( '.wp-element-caption', $stylesheet );
+ $this->assertMatchesRegularExpression( '/\.wp-element-caption\s*\{[^}]*#f78da7/', $stylesheet );
+ $this->assertDoesNotMatchRegularExpression( '/(^|[\s,}])caption\s*\{/', $stylesheet );
+
+ remove_filter( 'woocommerce_email_editor_theme_json', $add_caption_color, 20 );
+ }
+
/**
* Test if the theme controller returns correct preset variables map
*/
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 0bf0f8aad3d..c81a6eb7281 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
@@ -670,7 +670,7 @@ class Embed_Test extends \Email_Editor_Integration_Test_Case {
// The caption is wrapped in a centered div (matching content-editor.css),
// anchored to the caption text so the assertion cannot pass on the video
// renderer's own centered play-button wrapper.
- $this->assertStringContainsString( '<div style="text-align: center; margin-top: 8px;">My <strong>video</strong> caption</div>', $rendered );
+ $this->assertStringContainsString( '<div class="wp-element-caption" style="text-align: center; margin-top: 8px;">My <strong>video</strong> caption</div>', $rendered );
// The caption is appended after the embed output.
$thumbnail_pos = strpos( $rendered, 'img.youtube.com' );
$caption_pos = strpos( $rendered, 'My <strong>video</strong> caption' );
@@ -722,7 +722,7 @@ class Embed_Test extends \Email_Editor_Integration_Test_Case {
// The caption text is wrapped and appended, and the <img> is gone however
// the sanitizer disposed of it, so the trailing "</div>" completes nothing.
- $this->assertStringContainsString( '<div style="text-align: center; margin-top: 8px;">caption', $rendered );
+ $this->assertStringContainsString( '<div class="wp-element-caption" style="text-align: center; margin-top: 8px;">caption', $rendered );
$this->assertStringNotContainsString( '<img', $rendered );
}
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php
index a403e5eb59c..9850cf85c5c 100644
--- a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php
@@ -122,6 +122,36 @@ class Image_Test extends \Email_Editor_Integration_Test_Case {
$this->assertStringContainsString( 'text-align:center;', $rendered );
}
+ /**
+ * Test a caption saved without core's caption class still gets it.
+ *
+ * Image captions saved before WordPress 6.2 carry no class, and the email's caption styles key off
+ * that class, so without it a saved caption color never reaches them.
+ */
+ public function testItAddsTheCaptionClassToACaptionSavedWithoutIt(): void {
+ $image_content = str_replace( '</figure>', '<figcaption>Caption</figcaption></figure>', $this->image_content );
+ $parsed_image = $this->parsed_image;
+ $parsed_image['innerHTML'] = $image_content;
+
+ $rendered = $this->image_renderer->render( $image_content, $parsed_image, $this->rendering_context );
+
+ $this->assertStringContainsString( '<span class="wp-element-caption">Caption</span>', $rendered );
+ }
+
+ /**
+ * Test it keeps a caption's existing classes and does not repeat the caption class.
+ */
+ public function testItKeepsExistingCaptionClasses(): void {
+ $image_content = str_replace( '</figure>', '<figcaption class="custom-caption wp-element-caption">Caption</figcaption></figure>', $this->image_content );
+ $parsed_image = $this->parsed_image;
+ $parsed_image['innerHTML'] = $image_content;
+
+ $rendered = $this->image_renderer->render( $image_content, $parsed_image, $this->rendering_context );
+
+ $this->assertStringContainsString( '<span class="custom-caption wp-element-caption">Caption</span>', $rendered );
+ $this->assertSame( 1, substr_count( $rendered, 'wp-element-caption' ) );
+ }
+
/**
* Test it renders image alignment
*/
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Table_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Table_Test.php
index 986c5a14df6..87f5258c5dc 100644
--- a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Table_Test.php
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Table_Test.php
@@ -282,7 +282,7 @@ class Table_Test extends \Email_Editor_Integration_Test_Case {
$rendered = $this->table_renderer->render( $table_with_caption, $parsed_table, $this->rendering_context );
// Check that the caption content is preserved with proper styling outside the table.
- $this->assertStringContainsString( '<div style="text-align: center; margin-top: 8px;', $rendered );
+ $this->assertStringContainsString( '<div class="wp-element-caption" style="text-align: center; margin-top: 8px;', $rendered );
$this->assertStringContainsString( '>Table caption text</div>', $rendered );
// Check that the caption is not inside the table element.
$this->assertStringNotContainsString( '<caption', $rendered );
@@ -588,7 +588,7 @@ class Table_Test extends \Email_Editor_Integration_Test_Case {
$rendered = $this->table_renderer->render( $table_with_caption, $parsed_table, $this->rendering_context );
// Check that the caption appears outside the table as a div.
- $this->assertStringContainsString( '<div style="text-align: center; margin-top: 8px;', $rendered );
+ $this->assertStringContainsString( '<div class="wp-element-caption" style="text-align: center; margin-top: 8px;', $rendered );
$this->assertStringContainsString( '>Table caption text</div>', $rendered );
// Check that the caption is not inside the table element.
$this->assertStringNotContainsString( '<caption', $rendered );