Commit 9dc79559bf9 for woocommerce
commit 9dc79559bf94c9716ebd4d6873f8c151af779cee
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date: Wed Nov 4 09:43:44 2026 +0100
Inline block link colors in sent emails (#70571)
* Fix email block link colors during CSS inlining
* Add changelog entry for email block link colors
* Keep block link colors off email buttons
WordPress link rules skip buttons with :not(.wp-element-button), but
email buttons render as <a class="button-link"> or
<a class="product-button-link">. Once those rules are unwrapped and
inlined, a link color set on a Group replaced the text color of every
button inside it.
The normalizer now adds both button classes next to WordPress's
exclusion, and the root link rule uses the same list. The unit tests
also cover more forms the normalizer already handles: @import before a
rule, a comment before a rule, :root without a space and a long
declaration.
* Keep :where() when plain text would match other elements
Joining the prefix and argument as text turned `.x:where(div)` into
`.xdiv`, `.p :where(.a .b)` into `.p .a .b` (which needs .a inside .p)
and `:not(.x:where(div))` into `:not(.xdiv)`. These forms now keep
:where(). WordPress doesn't output them, but integrations can add them
through the style filters.
* Normalize template styles after their filter, and content styles once
Styles that integrations add through woocommerce_email_renderer_styles
went to the inliner as they were, so their :where() rules never reached
the email. Content styles were normalized both before and after their
filter; the call after the filter covers both, so it is now the only
one.
* Test a link color set on a block in the editor
This is the most common way a block gets its own link color. It goes
through the block-supports stylesheet, which no test covered.
* Describe in the README what happens to filtered email styles
* Match quoted strings without backtracking in the normalizer
A quoted value of about 10 KB or more, such as an inline SVG in a
data URL, ran the string pattern out of PCRE stack. preg_split then
failed and the whole stylesheet went to the inliner unchanged, so
layout and theme block rules were skipped as well. The string patterns
are now possessive.
* Mark the CSS selector normalizer as new in 2.20.0
* Leave the root link selector as it was
Adding :not(.product-button-link) to the site-wide link rule raised its
specificity, so it began to beat link rules that integrations add with
the same weight, such as `.my-footer a`. Product buttons always carry
their own inline color, so the old selector never changed their color.
Block link rules still skip product buttons.
* Share one string and comment pattern across the normalizer
The same quoted-string, comment and escape pattern was written out four
times, so a fix to one copy could miss the others, as the possessive
string fix nearly did. The selector prefix is now stripped of literals
once, and the button exclusion constant is private since nothing
outside the class uses it.
* Pass the layout rule to the content styles filter in its released form
Released versions hand woocommerce_email_content_renderer_styles the
layout rule as `> *:not(.alignleft)…`. Normalizing only after the
filter changed that to the raw `> :where(…)`, so a callback that
matches the old text would stop working. The styles are normalized
before the filter again, as in the contributor's version, and still
after it so that styles integrations add are inlined too.
* Say in the comment that the content filter gets every rule normalized
* Keep the content styles filter input as close to trunk as possible
Normalizing the whole stylesheet before the filter changed more than the
layout rule: callbacks also stopped seeing block-support link rules in
their :where() form, so code that edits them, such as MailPoet's
workaround, no longer matched. The filter gets trunk's targeted layout
replacement again, and the stylesheet is normalized only after it.
* Keep parent block link colors off the Site Title link in emails
Once block link rules are unwrapped and inlined, a Group's link color
outweighed the Site Title's `color: inherit` and came later in the
stylesheet, so the Site Title inside such a Group took the Group's link
color. In the editor the Site Title rule wins, since block link rules
there have zero specificity. Making its color important gives the email
the same result.
* Find the Site Title link with the tag helper, not by its name
The site name is escaped in the markup, so a test setup with & in it
would break a regex built from the raw name.
* Test that an inner block's link color wins inside a Group with one
When a Group and a Paragraph inside it both get a link color in the
editor, the email uses the Paragraph's color, which is what Gutenberg
also shows from WordPress 7.2. Both rules have the same weight, so only
their order in the block-supports stylesheet decides; the test keeps
that order from changing unnoticed.
* Show a link color set on the Site Title itself
The shared Site Title rule forced `color: inherit` on its link, so a
link color picked for the Site Title in the sidebar never showed, in
the editor or in the email. The rule now applies only when the Site
Title has no link color of its own, so it still keeps a parent Group's
link color off a plain Site Title.
* Keep the Site Title link without underline when it has its own color
Scoping the whole Site Title rule to titles without a link color also
dropped its text-decoration: none for titles with one. The underline
rule applies to every Site Title again, and only `color: inherit` is
scoped. That selector already outweighs block link rules, so the color
no longer needs !important.
---------
Co-authored-by: Bhavik Tank <bhavik.kalpesh@rtcamp.com>
diff --git a/packages/php/email-editor/README.md b/packages/php/email-editor/README.md
index 40b87b8ec7b..b04a5ff61a6 100644
--- a/packages/php/email-editor/README.md
+++ b/packages/php/email-editor/README.md
@@ -95,8 +95,8 @@ We may add, update and delete any of them.
|--------------------------------------------------------------------|------------------------------------------------------------------|--------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `woocommerce_email_editor_post_types` | `Array` $post_types | `Array` EmailPostType | Applied to the list of post types used by the `getPostTypes` method |
| `woocommerce_email_editor_theme_json` | `WP_Theme_JSON` $core_theme_data | `WP_Theme_JSON` $theme_json | Applied to the theme json data. This theme json data is created from the merging of the `WP_Theme_JSON_Resolver::get_core_data` and WooCommerce owns `theme.json` file |
-| `woocommerce_email_renderer_styles` | `string` $template_styles, `WP_Post` $post | `string` $template_styles | Applied to the email editor template styles. |
-| `woocommerce_email_content_renderer_styles` | `string` $content_styles, `WP_Post` $post | `string` $content_styles | Applied to the inline content styles prior to use by the CSS Inliner. |
+| `woocommerce_email_renderer_styles` | `string` $template_styles, `WP_Post` $post | `string` $template_styles | Applied to the email template styles. Afterwards, `:where()` is unwrapped where that keeps the selector’s meaning, and link rules skip email buttons. |
+| `woocommerce_email_content_renderer_styles` | `string` $content_styles, `WP_Post` $post | `string` $content_styles | Applied to the content styles before CSS inlining. Afterwards, `:where()` is unwrapped where that keeps the selector’s meaning, and link rules skip email buttons. |
| `woocommerce_is_email_editor_page` | `boolean` $is_editor_page | `boolean` | Check current page is the email editor page |
| `woocommerce_email_editor_send_preview_email` | `Array` $post_data | `boolean` Result of processing. Was email sent successfully? | Allows override of the send preview mail function. Folks may choose to use custom implementation |
| `woocommerce_email_editor_post_sent_status_args` | `Array` `sent` post status args | `Array` register_post_status args | Allows update of the argument for the sent post status |
diff --git a/packages/php/email-editor/changelog/fix-70302-block-link-colors b/packages/php/email-editor/changelog/fix-70302-block-link-colors
new file mode 100644
index 00000000000..6e7007da483
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-70302-block-link-colors
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix link colors set for individual email blocks, including colors selected from the theme palette.
diff --git a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
index 41d3d900a3c..0c240a8747e 100644
--- a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
+++ b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
@@ -684,7 +684,9 @@ class Content_Renderer {
);
$styles .= $block_support_styles;
+ $styles = wp_strip_all_tags( (string) apply_filters( 'woocommerce_email_content_renderer_styles', $styles, $post ) );
- return wp_strip_all_tags( (string) apply_filters( 'woocommerce_email_content_renderer_styles', $styles, $post ) );
+ // Normalize after the filter, so styles that integrations add here are inlined too.
+ return Css_Selector_Normalizer::normalize( $styles );
}
}
diff --git a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-css-selector-normalizer.php b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-css-selector-normalizer.php
new file mode 100644
index 00000000000..4ff0da08094
--- /dev/null
+++ b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-css-selector-normalizer.php
@@ -0,0 +1,185 @@
+<?php
+/**
+ * CSS selector normalization for email rendering.
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer;
+
+/**
+ * Converts WordPress :where() selectors to selectors supported by the CSS inliner.
+ *
+ * @internal
+ * @since 2.20.0
+ */
+class Css_Selector_Normalizer {
+ /**
+ * Selector part that keeps link rules off the buttons the email renderers output.
+ *
+ * WordPress link rules skip buttons with `:not(.wp-element-button)`. Email buttons carry these classes instead.
+ */
+ private const EMAIL_BUTTON_EXCLUSIONS = ':not(.button-link):not(.product-button-link)';
+
+ /**
+ * Pattern for a quoted string, without backtracking so long values such as data URLs can't exhaust PCRE.
+ */
+ private const STRING_PATTERN = <<<'REGEX'
+"[^"\\]*+(?:\\.[^"\\]*+)*+"|'[^'\\]*+(?:\\.[^'\\]*+)*+'
+REGEX;
+
+ /**
+ * Pattern for text whose characters are not selector syntax: a quoted string, a comment or an escaped character.
+ */
+ private const LITERAL_PATTERN = self::STRING_PATTERN . '|/\*.*?\*/|\\\\.';
+
+ /**
+ * Normalize rule selectors without changing declarations or at-rule conditions.
+ *
+ * @since 2.20.0
+ * @param string $css Collected stylesheet.
+ * @return string
+ */
+ public static function normalize( string $css ): string {
+ if ( false === stripos( $css, ':where(' ) && false === strpos( $css, ':not(.wp-element-button)' ) ) {
+ return $css;
+ }
+
+ $tokens = preg_split( '~(' . self::LITERAL_PATTERN . '|[{};])~s', $css, -1, PREG_SPLIT_DELIM_CAPTURE );
+ $result = '';
+ $prelude = '';
+ if ( false === $tokens ) {
+ return $css;
+ }
+
+ foreach ( $tokens as $token ) {
+ if ( '{' === $token ) {
+ $prelude = self::normalize_rule( $prelude );
+ }
+ $prelude .= $token;
+ if ( in_array( $token, array( '{', '}', ';' ), true ) ) {
+ $result .= $prelude;
+ $prelude = '';
+ }
+ }
+
+ return $result . $prelude;
+ }
+
+ /**
+ * Normalize the selector list before a declaration block.
+ *
+ * @param string $prelude Rule selector or at-rule condition.
+ * @return string
+ */
+ private static function normalize_rule( string $prelude ): string {
+ $selector = ltrim( (string) preg_replace( '~/\*.*?\*/~s', '', $prelude ) );
+ if ( 0 === strpos( $selector, '@' ) ) {
+ return $prelude;
+ }
+
+ if ( false !== stripos( $prelude, ':where(' ) ) {
+ $selectors = array();
+ foreach ( self::split_selectors( $prelude ) as $part ) {
+ $selectors = array_merge( $selectors, self::expand_where( $part ) );
+ }
+ $prelude = implode( ', ', $selectors );
+ }
+
+ // Without `:where()` a link rule would beat the email button’s own color.
+ return (string) preg_replace( '/:not\(\.wp-element-button\)(?!' . preg_quote( self::EMAIL_BUTTON_EXCLUSIONS, '/' ) . ')/', ':not(.wp-element-button)' . self::EMAIL_BUTTON_EXCLUSIONS, $prelude );
+ }
+
+ /**
+ * Expand selector lists inside :where(), including nested functions.
+ *
+ * @param string $selector Rule selector.
+ * @return string[]
+ */
+ private static function expand_where( string $selector ): array {
+ $pattern = '~(?:' . self::LITERAL_PATTERN . ')(*SKIP)(*F)|:where\((?<inner>(?:[^()"\'\\\\]+|\\\\.|' . self::STRING_PATTERN . '|\((?&inner)\))*)\)~is';
+ if ( ! preg_match( $pattern, $selector, $matches, PREG_OFFSET_CAPTURE ) || '' === trim( $matches['inner'][0] ) ) {
+ return array( trim( $selector ) );
+ }
+
+ $before = substr( $selector, 0, $matches[0][1] );
+ $after = substr( $selector, $matches[0][1] + strlen( $matches[0][0] ) );
+ $selectors = array();
+ $parts = self::split_selectors( $matches['inner'][0] );
+
+ // Keep the existing treatment of WordPress’s :root :where(...) wrapper.
+ $before = (string) preg_replace( '/:root\s+$/i', '', $before );
+ $prefix = (string) preg_replace( '~' . self::LITERAL_PATTERN . '~s', '', $before );
+ if ( substr_count( $prefix, '(' ) > substr_count( $prefix, ')' ) ) {
+ // Inside another function, only a single `:where()` that is the whole argument keeps its meaning as plain text.
+ if ( count( $parts ) > 1 || ! preg_match( '/\(\s*$/', $prefix ) || ! preg_match( '/^\s*\)/', $after ) ) {
+ return array( $selector );
+ }
+ }
+
+ if ( '' !== ltrim( $prefix ) && ! self::can_follow( ltrim( $prefix ), $parts ) ) {
+ return array( $selector );
+ }
+ foreach ( $parts as $part ) {
+ $universal = preg_match( '/(?:^|[\s>+~])$/', $before ) && 0 === strpos( $part, ':' ) ? '*' : '';
+ $selectors = array_merge( $selectors, self::expand_where( $before . $universal . $part . $after ) );
+ }
+
+ return $selectors;
+ }
+
+ /**
+ * Check that each `:where()` argument means the same as plain text after the given prefix.
+ *
+ * An argument with a combinator, such as `.a .b`, would match other elements, and so would a type selector joined to the prefix.
+ *
+ * @param string $prefix Selector text before `:where()`, without comments and strings.
+ * @param string[] $parts The `:where()` arguments.
+ * @return bool
+ */
+ private static function can_follow( string $prefix, array $parts ): bool {
+ $joined = ! preg_match( '/[\s>+~(]$/', $prefix );
+ foreach ( $parts as $part ) {
+ $outer = (string) preg_replace( '/\[[^\]]*\]|\((?:[^()]++|(?R))*\)/', '', $part );
+ if ( preg_match( '/[\s>+~]/', trim( $outer ) ) || ( $joined && preg_match( '/^[a-z*|\\\\]/i', $part ) ) ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Split a selector list while keeping commas inside functions and strings.
+ *
+ * @param string $selector Selector list.
+ * @return string[]
+ */
+ private static function split_selectors( string $selector ): array {
+ $tokens = preg_split( '~(' . self::LITERAL_PATTERN . '|[(),])~s', $selector, -1, PREG_SPLIT_DELIM_CAPTURE );
+ if ( false === $tokens ) {
+ return array( $selector );
+ }
+
+ $selectors = array();
+ $current = '';
+ $depth = 0;
+
+ foreach ( $tokens as $token ) {
+ if ( '(' === $token ) {
+ ++$depth;
+ } elseif ( ')' === $token ) {
+ --$depth;
+ } elseif ( ',' === $token && 0 === $depth ) {
+ $selectors[] = trim( $current );
+ $current = '';
+ continue;
+ }
+ $current .= $token;
+ }
+ $selectors[] = trim( $current );
+
+ return $selectors;
+ }
+}
diff --git a/packages/php/email-editor/src/Engine/Renderer/class-renderer.php b/packages/php/email-editor/src/Engine/Renderer/class-renderer.php
index b37551d8465..7d4eb418b56 100644
--- a/packages/php/email-editor/src/Engine/Renderer/class-renderer.php
+++ b/packages/php/email-editor/src/Engine/Renderer/class-renderer.php
@@ -9,6 +9,7 @@ declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Content_Renderer;
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Css_Selector_Normalizer;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Process_Manager;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Engine\Templates\Templates;
@@ -161,7 +162,7 @@ class Renderer {
);
$template_styles .= '.email_layout_wrapper { box-sizing: border-box;}';
$template_styles .= file_get_contents( __DIR__ . '/' . self::TEMPLATE_STYLES_FILE );
- $template_styles = wp_strip_all_tags( (string) apply_filters( 'woocommerce_email_renderer_styles', $template_styles, $post ) );
+ $template_styles = Css_Selector_Normalizer::normalize( wp_strip_all_tags( (string) apply_filters( 'woocommerce_email_renderer_styles', $template_styles, $post ) ) );
// Single CSS inlining pass: combine content and template styles, then inline all at once.
$all_styles = '<style>' . $template_styles . $content_styles . '</style>';
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 220d85dadf0..e4400c63ed5 100644
--- a/packages/php/email-editor/src/Engine/class-theme-controller.php
+++ b/packages/php/email-editor/src/Engine/class-theme-controller.php
@@ -8,6 +8,7 @@
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine;
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Css_Selector_Normalizer;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Preset_Variable_Resolver;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
use WP_Block_Template;
@@ -226,9 +227,7 @@ class Theme_Controller {
foreach ( $blocks as $block_metadata ) {
$css_blocks .= $this->get_theme()->get_styles_for_block( $block_metadata );
}
-
- // Remove `:root :where(...)` selectors since they are not supported in the CSS inliner.
- $css_blocks = preg_replace( '/:root\s:where\((.*?)\)/', '$1', $css_blocks );
+ $css_blocks = Css_Selector_Normalizer::normalize( $css_blocks );
// Element specific styles.
$elements_styles = $this->get_theme()->get_raw_data()['styles']['elements'] ?? array();
diff --git a/packages/php/email-editor/src/Engine/content-shared.css b/packages/php/email-editor/src/Engine/content-shared.css
index dcfa3fe1d44..c772f3e6bd8 100644
--- a/packages/php/email-editor/src/Engine/content-shared.css
+++ b/packages/php/email-editor/src/Engine/content-shared.css
@@ -23,5 +23,9 @@ ol.has-background {
/* Remove default browser underline from site title link */
.wp-block-site-title a:not(.wp-element-button) {
text-decoration: none !important;
+}
+
+/* Keep link colors from parent blocks off a site title without a link color of its own */
+.wp-block-site-title:not(.has-link-color) a:not(.wp-element-button) {
color: inherit;
}
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 398ffa8f80f..160bb16c4ff 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
@@ -180,6 +180,108 @@ class Content_Renderer_Test extends \Email_Editor_Integration_Test_Case {
$this->assertStringContainsString( 'display: block', $paragraph_styles );
}
+ /**
+ * Test block link styles through the complete rendering pipeline.
+ *
+ * @testdox Block link colors from theme.json are inlined and palette presets are resolved.
+ * @testWith ["#123456"]
+ * ["var:preset|color|author-link"]
+ *
+ * @param string $color Theme.json link color.
+ */
+ public function test_render_inlines_block_link_colors( string $color ): void {
+ add_filter( 'woocommerce_email_editor_site_style_sync_enabled', '__return_false' );
+ add_filter(
+ 'woocommerce_email_editor_theme_json',
+ static function ( $theme ) use ( $color ) {
+ $theme->merge(
+ new \WP_Theme_JSON(
+ array(
+ 'version' => 3,
+ 'settings' => array(
+ 'color' => array(
+ 'palette' => array(
+ array(
+ 'slug' => 'author-link',
+ 'name' => 'Author link',
+ 'color' => '#123456',
+ ),
+ ),
+ ),
+ ),
+ 'styles' => array(
+ 'elements' => array( 'link' => array( 'color' => array( 'text' => '#654321' ) ) ),
+ 'blocks' => array(
+ 'core/post-author' => array(
+ 'elements' => array( 'link' => array( 'color' => array( 'text' => $color ) ) ),
+ ),
+ ),
+ ),
+ ),
+ 'custom'
+ )
+ );
+ return $theme;
+ }
+ );
+ $author_id = $this->factory->user->create( array( 'display_name' => 'Email author' ) );
+ $this->assertIsInt( $author_id );
+ wp_update_post(
+ array(
+ 'ID' => $this->email_post->ID,
+ 'post_author' => $author_id,
+ )
+ );
+ $email_post = get_post( $this->email_post->ID );
+ $this->assertInstanceOf( \WP_Post::class, $email_post );
+ $this->email_post = $email_post;
+ $template = new \WP_Block_Template();
+ $template->id = 'template-id';
+ $template->content = '<!-- wp:post-author {"isLink":true,"showAvatar":false} /-->';
+
+ $content = $this->renderer->render( $this->email_post, $template );
+ $styles = $this->getStylesValueForTag( $content, 'a' );
+
+ $this->assertStringContainsString( '<a ', $content, 'The Post Author block should render a link.' );
+ $this->assertIsString( $styles, 'The author link should have inline styles.' );
+ $this->assertStringContainsString( 'color: #123456', $styles, 'The block link color should be inlined as its palette value.' );
+ $this->assertStringNotContainsString( 'var(', $styles, 'The sent link should not depend on CSS custom properties.' );
+ }
+
+ /**
+ * Test the existing root and layout selector behavior after normalization.
+ *
+ * @testdox Root and layout selectors keep their exclusions and existing inline colors.
+ * @testWith [":root :where(.selector-test a:where(:not(.alignfull)))"]
+ * [".selector-test > :where(:not(.alignleft):not(.alignright):not(.alignfull))"]
+ *
+ * @param string $selector WordPress root or layout selector.
+ */
+ public function test_render_inlines_root_and_layout_selectors( string $selector ): void {
+ add_filter(
+ 'woocommerce_email_content_renderer_styles',
+ static function ( $styles ) use ( $selector ) {
+ return $styles . $selector . ' { color: #123456; }';
+ }
+ );
+ $template = new \WP_Block_Template();
+ $template->id = 'template-id';
+ $template->content = '<!-- wp:paragraph --><p class="selector-test"><a href="https://example.com">Link</a><a class="alignfull" href="https://example.com">Excluded</a><a style="color:#abcdef" href="https://example.com">Custom color</a></p><!-- /wp:paragraph -->';
+
+ $content = $this->renderer->render( $this->email_post, $template );
+ $html = new \WP_HTML_Tag_Processor( $content );
+ $colors = array();
+ while ( $html->next_tag( array( 'tag_name' => 'a' ) ) ) {
+ $style = $html->get_attribute( 'style' );
+ $colors[] = is_string( $style ) ? $style : '';
+ }
+
+ $this->assertCount( 3, $colors, 'All three links should render.' );
+ $this->assertStringContainsString( 'color: #123456', $colors[0], 'The root or layout selector should inline its color.' );
+ $this->assertStringNotContainsString( '#123456', $colors[1], 'The alignfull link must stay excluded.' );
+ $this->assertStringContainsString( 'color: #abcdef', $colors[2], 'The explicit inline color must retain precedence.' );
+ }
+
/**
* Test render_without_css_inline() returns HTML and collected CSS.
*/
@@ -691,6 +793,219 @@ class Content_Renderer_Test extends \Email_Editor_Integration_Test_Case {
\WP_Block_Type_Registry::get_instance()->unregister( 'test/asymmetric-padding-block' );
}
+ /**
+ * Test a link color set on a block in the editor, which WordPress prints in the block-supports stylesheet, is inlined.
+ */
+ public function testItInlinesALinkColorSetOnABlock(): void {
+ $template = new \WP_Block_Template();
+ $template->id = 'template-id';
+ $template->content = '<!-- wp:post-content /-->';
+ $email_post = $this->create_email_post( '<!-- wp:paragraph {"style":{"elements":{"link":{"color":{"text":"var:preset|color|vivid-red"}}}}} --><p class="has-link-color">Read <a href="https://example.com">more</a></p><!-- /wp:paragraph -->' );
+
+ try {
+ $rendered = $this->renderer->render( $email_post, $template );
+ } finally {
+ \WP_Style_Engine_CSS_Rules_Store::remove_all_stores();
+ }
+
+ $this->assertStringContainsString( 'color: #cf2e2e', (string) $this->getStylesValueForTag( $rendered, 'a' ) );
+ }
+
+ /**
+ * Test the content styles filter still receives the layout rule in the `*:not(…)` form released versions passed to it.
+ */
+ public function testItPassesTheLayoutRuleToTheContentFilterInItsReleasedForm(): void {
+ $selector = '.wp-container-core-columns-is-layout-1 > :where(:not(.alignleft):not(.alignright):not(.alignfull))';
+ wp_style_engine_get_stylesheet_from_css_rules(
+ array(
+ array(
+ 'rules_group' => '',
+ 'selector' => $selector,
+ 'declarations' => array( 'max-width' => '321px' ),
+ ),
+ ),
+ array( 'context' => 'block-supports' )
+ );
+ $received = '';
+ $capture = static function ( string $styles ) use ( &$received ): string {
+ $received = $styles;
+ return $styles;
+ };
+ add_filter( 'woocommerce_email_content_renderer_styles', $capture );
+ $template = new \WP_Block_Template();
+ $template->id = 'template-id';
+ $template->content = '<!-- wp:post-content /-->';
+
+ try {
+ $this->renderer->render_without_css_inline( $this->email_post, $template );
+ } finally {
+ remove_filter( 'woocommerce_email_content_renderer_styles', $capture );
+ $store = \WP_Style_Engine_CSS_Rules_Store::get_store( 'block-supports' );
+ if ( $store instanceof \WP_Style_Engine_CSS_Rules_Store ) {
+ $store->remove_rule( $selector );
+ }
+ }
+
+ $this->assertStringContainsString( '.wp-container-core-columns-is-layout-1 > *:not(.alignleft):not(.alignright):not(.alignfull)', $received );
+ }
+
+ /**
+ * Test content styles added through the filter are inlined when they use `:where()`.
+ */
+ public function testItInlinesFilteredContentStylesWithWhere(): void {
+ $add_styles = static function ( string $styles ): string {
+ return $styles . '.wp-block-paragraph :where(a){color: #0a0b0c;}';
+ };
+ add_filter( 'woocommerce_email_content_renderer_styles', $add_styles );
+
+ $template = new \WP_Block_Template();
+ $template->id = 'template-id';
+ $template->content = '<!-- wp:post-content /-->';
+ $email_post = $this->create_email_post( '<!-- wp:paragraph --><p class="wp-block-paragraph">Read <a href="https://example.com">more</a></p><!-- /wp:paragraph -->' );
+
+ try {
+ $rendered = $this->renderer->render( $email_post, $template );
+ } finally {
+ remove_filter( 'woocommerce_email_content_renderer_styles', $add_styles );
+ }
+
+ $this->assertStringContainsString( 'color: #0a0b0c', (string) $this->getStylesValueForTag( $rendered, 'a' ) );
+ }
+
+ /**
+ * Test a link color set on a Paragraph wins over the link color of the Group around it, when both are set in the editor.
+ *
+ * Both rules have the same weight, so the inner block's rule wins because WordPress prints it later.
+ */
+ public function testItUsesTheInnerBlockLinkColorInsideAGroupWithALinkColor(): void {
+ $template = new \WP_Block_Template();
+ $template->id = 'template-id';
+ $template->content = '<!-- wp:post-content /-->';
+ $email_post = $this->create_email_post( '<!-- wp:group {"style":{"elements":{"link":{"color":{"text":"#123456"}}}}} --><div class="wp-block-group has-link-color"><!-- wp:paragraph {"style":{"elements":{"link":{"color":{"text":"#654321"}}}}} --><p class="has-link-color">Read <a href="https://example.com">more</a></p><!-- /wp:paragraph --></div><!-- /wp:group -->' );
+
+ try {
+ $rendered = $this->renderer->render( $email_post, $template );
+ } finally {
+ \WP_Style_Engine_CSS_Rules_Store::remove_all_stores();
+ }
+
+ $this->assertStringContainsString( 'color: #654321', (string) $this->getStylesValueForTag( $rendered, 'a' ) );
+ }
+
+ /**
+ * Test a Site Title link keeps inheriting its color inside a Group that has a link color, as in the editor.
+ */
+ public function testItKeepsTheSiteTitleLinkColorInsideAGroupWithALinkColor(): void {
+ $template = new \WP_Block_Template();
+ $template->id = 'template-id';
+ $template->content = '<!-- wp:post-content /-->';
+ $email_post = $this->create_email_post( '<!-- wp:group {"style":{"elements":{"link":{"color":{"text":"#123456"}}}}} --><div class="wp-block-group has-link-color"><!-- wp:site-title {"isLink":true} /--></div><!-- /wp:group -->' );
+
+ try {
+ $rendered = $this->renderer->render( $email_post, $template );
+ } finally {
+ \WP_Style_Engine_CSS_Rules_Store::remove_all_stores();
+ }
+
+ // The Site Title is the only link in this email.
+ $site_title_link_styles = (string) $this->getStylesValueForTag( $rendered, 'a' );
+ $this->assertStringContainsString( 'color: inherit', $site_title_link_styles );
+ $this->assertStringNotContainsString( '#123456', $site_title_link_styles );
+ }
+
+ /**
+ * Test a link color set on the Site Title itself shows, also inside a Group that has a link color.
+ */
+ public function testItUsesTheSiteTitleOwnLinkColor(): void {
+ $template = new \WP_Block_Template();
+ $template->id = 'template-id';
+ $template->content = '<!-- wp:post-content /-->';
+ $email_post = $this->create_email_post( '<!-- wp:group {"style":{"elements":{"link":{"color":{"text":"#123456"}}}}} --><div class="wp-block-group has-link-color"><!-- wp:site-title {"isLink":true,"style":{"elements":{"link":{"color":{"text":"#ab1234"}}}}} /--></div><!-- /wp:group -->' );
+
+ try {
+ $rendered = $this->renderer->render( $email_post, $template );
+ } finally {
+ \WP_Style_Engine_CSS_Rules_Store::remove_all_stores();
+ }
+
+ // The Site Title is the only link in this email.
+ $site_title_link_styles = (string) $this->getStylesValueForTag( $rendered, 'a' );
+ $this->assertStringContainsString( 'color: #ab1234', $site_title_link_styles );
+ $this->assertStringContainsString( 'text-decoration: none', $site_title_link_styles );
+ }
+
+ /**
+ * Test a link color set on a block does not reach the buttons inside it.
+ */
+ public function testItKeepsBlockLinkColorsOffButtons(): void {
+ $theme_json_filter = static function ( \WP_Theme_JSON $theme ): \WP_Theme_JSON {
+ $theme->merge(
+ new \WP_Theme_JSON(
+ array(
+ 'version' => 3,
+ 'styles' => array(
+ 'blocks' => array(
+ 'core/group' => array( 'elements' => array( 'link' => array( 'color' => array( 'text' => '#123456' ) ) ) ),
+ ),
+ ),
+ ),
+ 'custom'
+ )
+ );
+ return $theme;
+ };
+ add_filter( 'woocommerce_email_editor_theme_json', $theme_json_filter );
+
+ $template = new \WP_Block_Template();
+ $template->id = 'template-id';
+ $template->content = '<!-- wp:post-content /-->';
+ $email_post = $this->create_email_post( '<!-- wp:group --><div class="wp-block-group"><!-- wp:buttons --><div class="wp-block-buttons"><!-- wp:button --><div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://example.com">Buy</a></div><!-- /wp:button --></div><!-- /wp:buttons --></div><!-- /wp:group -->' );
+
+ // Register core/button with its email renderer, as testItAppliesASavedCaptionColorToAnImageCaption does for core/image.
+ $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/button' );
+ $this->assertInstanceOf( \WP_Block_Type::class, $original_block );
+ $registry->unregister( 'core/button' );
+ register_block_type(
+ 'core/button',
+ $core_integration->update_block_settings(
+ array(
+ 'title' => $original_block->title,
+ 'name' => $original_block->name,
+ 'category' => $original_block->category,
+ 'supports' => $original_block->supports ?? array(),
+ )
+ )
+ );
+
+ try {
+ $rendered = $this->renderer->render( $email_post, $template );
+ } finally {
+ $registry->unregister( 'core/button' );
+ $registry->register( $original_block );
+ remove_filter( 'woocommerce_email_editor_theme_json', $theme_json_filter );
+ }
+
+ $button_styles = (string) $this->getStylesValueForTag( $rendered, 'a' );
+ $this->assertStringContainsString( 'color: #fff', $button_styles );
+ $this->assertStringNotContainsString( '#123456', $button_styles );
+ }
+
+ /**
+ * Create an email post with the given content.
+ *
+ * @param string $content Post content.
+ */
+ private function create_email_post( string $content ): \WP_Post {
+ $email_post_id = $this->factory->post->create( array( 'post_content' => $content ) );
+ $this->assertIsInt( $email_post_id );
+ $email_post = get_post( $email_post_id );
+ $this->assertInstanceOf( \WP_Post::class, $email_post );
+ return $email_post;
+ }
+
/**
* Get the value of the style attribute for a given tag in the HTML.
*
diff --git a/packages/php/email-editor/tests/integration/Engine/Renderer/Renderer_Test.php b/packages/php/email-editor/tests/integration/Engine/Renderer/Renderer_Test.php
index 71da836df64..66ce78c556c 100644
--- a/packages/php/email-editor/tests/integration/Engine/Renderer/Renderer_Test.php
+++ b/packages/php/email-editor/tests/integration/Engine/Renderer/Renderer_Test.php
@@ -300,6 +300,22 @@ class Renderer_Test extends \Email_Editor_Integration_Test_Case {
$this->assertStringContainsString( 'max-width: 660px;', $style );
}
+ /**
+ * Test template styles added through the filter are inlined when they use `:where()`.
+ */
+ public function testItInlinesFilteredTemplateStylesWithWhere(): void {
+ $add_styles = static function ( string $styles ): string {
+ return $styles . ':where(.email_layout_wrapper){border-top: 7px solid #010203;}';
+ };
+ add_filter( 'woocommerce_email_renderer_styles', $add_styles );
+ try {
+ $rendered = $this->renderer->render( $this->email_post, 'Subject', '', 'en' );
+ } finally {
+ remove_filter( 'woocommerce_email_renderer_styles', $add_styles );
+ }
+ $this->assertMatchesRegularExpression( '/class="email_layout_wrapper[^"]*"[^>]*style="[^"]*border-top: 7px solid #010203/', $rendered['html'] );
+ }
+
/**
* Test it renders post wrapped within a template associated with the post via _wp_page_template post meta.
*/
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 059d87f3962..cfeb5c7e01a 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
@@ -304,6 +304,39 @@ class Theme_Controller_Test extends \Email_Editor_Integration_Test_Case {
remove_filter( 'woocommerce_email_editor_theme_json', $add_caption_color, 20 );
}
+ /**
+ * Test the rendering stylesheet has no `:where()` selectors, which the CSS inliner skips, for a block link color.
+ */
+ public function testItUnwrapsWhereSelectorsForBlockLinkColors(): void {
+ $add_link_color = function ( \WP_Theme_JSON $theme ): \WP_Theme_JSON {
+ $theme->merge(
+ new \WP_Theme_JSON(
+ array(
+ 'version' => 3,
+ 'styles' => array(
+ 'blocks' => array(
+ 'core/paragraph' => array(
+ 'elements' => array( 'link' => array( 'color' => array( 'text' => '#123456' ) ) ),
+ ),
+ ),
+ ),
+ ),
+ 'custom'
+ )
+ );
+ return $theme;
+ };
+ add_filter( 'woocommerce_email_editor_theme_json', $add_link_color, 20 );
+
+ try {
+ $stylesheet = $this->di_container->get( Theme_Controller::class )->get_stylesheet_for_rendering();
+ } finally {
+ remove_filter( 'woocommerce_email_editor_theme_json', $add_link_color, 20 );
+ }
+ $this->assertStringContainsString( 'p a:not(.wp-element-button):not(.button-link):not(.product-button-link){color: #123456;}', $stylesheet );
+ $this->assertStringNotContainsString( ':where(', $stylesheet );
+ }
+
/**
* Test if the theme controller returns correct preset variables map
*/
diff --git a/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Css_Selector_Normalizer_Test.php b/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Css_Selector_Normalizer_Test.php
new file mode 100644
index 00000000000..3b709127511
--- /dev/null
+++ b/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Css_Selector_Normalizer_Test.php
@@ -0,0 +1,139 @@
+<?php
+/**
+ * Tests for email CSS selector normalization.
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer;
+
+/**
+ * Tests for Css_Selector_Normalizer.
+ */
+class Css_Selector_Normalizer_Test extends \Email_Editor_Unit_Test {
+
+ /**
+ * Test collected styles with nested selectors, strings, and conditions.
+ *
+ * @testdox Normalization unwraps nested selectors and preserves CSS strings and conditions.
+ * @dataProvider stylesheet_provider
+ *
+ * @param string $css Collected stylesheet.
+ * @param string $expected Expected normalized stylesheet.
+ */
+ public function test_normalize( string $css, string $expected ): void {
+ $this->assertSame( $expected, Css_Selector_Normalizer::normalize( $css ) );
+ }
+
+ /**
+ * Stylesheets generated by WordPress and integrations.
+ *
+ * @return array<string, string[]>
+ */
+ public function stylesheet_provider(): array {
+ return array(
+ 'nested link selector' => array(
+ ':root :where(.wp-block-post-author a:where(:not(.wp-element-button))) { color: #123456; }',
+ '.wp-block-post-author a:not(.wp-element-button):not(.button-link):not(.product-button-link){ color: #123456; }',
+ ),
+ 'layout exclusions' => array(
+ '.group > :where(:not(.alignleft):not(.alignright):not(.alignfull)) { max-width: 660px; }',
+ '.group > *:not(.alignleft):not(.alignright):not(.alignfull){ max-width: 660px; }',
+ ),
+ 'selector lists' => array(
+ '.scope :where(a, span) :where(.first, .second), .other { color: red; }',
+ '.scope a .first, .scope a .second, .scope span .first, .scope span .second, .other{ color: red; }',
+ ),
+ 'quoted selector and declaration values' => array(
+ 'a[data-label=":where(a,b)"] :where(span) { content: ":where(a) { ; }"; }',
+ 'a[data-label=":where(a,b)"] span{ content: ":where(a) { ; }"; }',
+ ),
+ 'nested function with quoted parentheses' => array(
+ ':where(a:not([data-label=")"])) { color: red; }',
+ 'a:not([data-label=")"]){ color: red; }',
+ ),
+ 'at-rule condition' => array(
+ '@supports selector(:where(a)) { .scope :where(a) { color: red; } }',
+ '@supports selector(:where(a)) {.scope a{ color: red; } }',
+ ),
+ 'no functional selector' => array(
+ 'a { color: red; }',
+ 'a { color: red; }',
+ ),
+ 'list inside negation remains unsupported' => array(
+ 'a:not(:where(.first, .second)) { color: red; }',
+ 'a:not(:where(.first, .second)){ color: red; }',
+ ),
+ 'type joined to a prefix is kept' => array(
+ '.x:where(div){color:red}',
+ '.x:where(div){color:red}',
+ ),
+ 'combinator after a descendant is kept' => array(
+ '.p :where(.a .b){color:red}',
+ '.p :where(.a .b){color:red}',
+ ),
+ 'combinator after a child is kept' => array(
+ '.x > :where(.a .b){color:red}',
+ '.x > :where(.a .b){color:red}',
+ ),
+ 'combinator with no prefix' => array(
+ '.c :where(.a){color:red} :where(.a .b) .c{color:red}',
+ '.c .a{color:red}.a .b .c{color:red}',
+ ),
+ 'nested type selector is kept' => array(
+ ':not(.x:where(div)){color:red}',
+ ':not(.x:where(div)){color:red}',
+ ),
+ 'nested combinator is kept' => array(
+ 'a:not(.a:where(.b .c)){color:red}',
+ 'a:not(.a:where(.b .c)){color:red}',
+ ),
+ 'nested whole argument' => array(
+ 'a:not(:where(.b)){color:red}',
+ 'a:not(.b){color:red}',
+ ),
+ 'root without a space' => array(
+ ':root:where(.a){color:red}',
+ ':root.a{color:red}',
+ ),
+ 'rule after an import' => array(
+ '@import url(x.css);.p :where(.a){color:red}',
+ '@import url(x.css);.p .a{color:red}',
+ ),
+ 'comment before the rule' => array(
+ '/* don\'t */ :root :where(.a){color:red}',
+ '/* don\'t */ .a{color:red}',
+ ),
+ 'link rule skips email buttons' => array(
+ '.wp-elements-1 a:where(:not(.wp-element-button)){color:red}',
+ '.wp-elements-1 a:not(.wp-element-button):not(.button-link):not(.product-button-link){color:red}',
+ ),
+ 'link rule without where skips email buttons' => array(
+ '.t a:not(.wp-element-button){color:red}',
+ '.t a:not(.wp-element-button):not(.button-link):not(.product-button-link){color:red}',
+ ),
+ 'button exclusion is added once' => array(
+ 'a:not(.wp-element-button):not(.button-link):not(.product-button-link){color:red}',
+ 'a:not(.wp-element-button):not(.button-link):not(.product-button-link){color:red}',
+ ),
+ );
+ }
+
+ /**
+ * Test a stylesheet with a very long declaration, such as an embedded font, is still normalized.
+ */
+ public function test_normalize_with_a_long_declaration(): void {
+ $font = '.a{src:url(data:font/woff2;base64,' . str_repeat( 'A', 1100000 ) . ');}';
+ $this->assertSame( $font . '.b .c{color:red}', Css_Selector_Normalizer::normalize( $font . '.b :where(.c){color:red}' ) );
+ }
+
+ /**
+ * Test a stylesheet with a very long quoted value, such as an inline SVG, is still normalized.
+ */
+ public function test_normalize_with_a_long_quoted_value(): void {
+ $svg = '.a{background:url("data:image/svg+xml,' . str_repeat( 'A', 1100000 ) . '");}';
+ $this->assertSame( $svg . '.b .c{color:red}', Css_Selector_Normalizer::normalize( $svg . '.b :where(.c){color:red}' ) );
+ }
+}