Commit bcebbe955d4 for woocommerce
commit bcebbe955d44f877ef37c6dd5b75db89f7aa322c
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date: Wed Nov 11 14:07:19 2026 +0100
Keep personalization tags and tag links intact in plain-text emails (#70819)
* Keep tags and tag links intact in the plain-text email
Three gaps made the plain-text version lose or show tags:
- The Renderer protected only tokens of tags registered at render
time, so Html2Text dropped the comments of tags a plugin registers
later. It now keeps every tag-shaped comment; the registry argument
stays in the constructor because integrators build the Renderer.
- In the text context an unresolved token was left as a comment, which
the recipient sees as text. It is now removed; HTML keeps it.
- Links whose URL is a tag ended up as "[text](http://%5Btag%5D)" or
lost the URL when it lived only in data-link-href. The text version
now uses data-link-href as the link target, and the Personalizer
fills tags in "[text](url)" targets with the href context.
* Leave no raw tag in plain-text links and drop the unused registry
Copying data-link-href into href let a raw tag reach the plain-text
email: a tag nobody registered, a tag with arguments (the target
pattern rejected the space) and an image link without alt text, where
Html2Text writes the bare target. Html2Text now uses data-link-href as
the target and drops links without text, and the Personalizer keeps
only the link text when such a target cannot be resolved.
The link pass now runs before tag values are inserted, so a value that
looks like a link target is not personalized again.
The Renderer releases the registry argument with unset() instead of a
PHPStan ignore, and its tests no longer pass a registry it never reads.
* Match only the target of plain-text links
Parsing the link text as well made the pass miss links whose text holds
a tag comment or brackets, such as "Hi <!--[tag]-->" or an image alt
next to words, so their tag targets stayed raw. The pass now matches
"](target)" alone and drops an unresolvable bare-tag target, leaving
"[text]". The docblock now matches the docs on what is removed.
* Leave a tag empty when its callback throws instead of failing the email
A callback that threw stopped the whole personalization pass, so one
broken tag decided whether the email was sent at all. The Personalizer
now catches the error, logs it with the tag token and uses an empty
value. The logger is an optional last constructor argument.
* Mark bold text with asterisks in the plain-text email
Bold text read the same as the text around it in the plain-text
version. Html2Text gets a mark_bold option that writes *text*; only
the Renderer's plain-text body turns it on, so tag values converted
for subjects stay without markers.
* Leave bold tags unmarked in the plain-text email
Html2Text marks bold before tags are filled in, so a bold tag with an
empty value, such as a guest's missing first name, left a stray "**".
Bold text made of only a tag now gets no markers.
* Personalize plain-text link targets that contain parentheses
A URL such as https://example.org/a_(b)?ref=[tag] did not match the
link target pattern, so its tag stayed raw in the plain-text email.
The target may now hold one level of parentheses.
* Mark bold text with string functions instead of a regex
The repository avoids regexes where string functions do the job, and a
failed preg_replace() would have dropped the bold text.
* Use data-link-href as the plain-text link target only when asked
Html2Text::convert() also serves callers outside the Renderer, such as
MailPoet's mailer for site emails, where an unresolved tag link would
now read [here]([other/x]) instead of here. The behaviour moves behind
a tag_link_targets option, off by default; the Renderer turns it on.
diff --git a/packages/php/email-editor/changelog/fix-wooplug-8217-plain-text-content b/packages/php/email-editor/changelog/fix-wooplug-8217-plain-text-content
new file mode 100644
index 00000000000..b803c0f6877
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-wooplug-8217-plain-text-content
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep personalization tags in plain-text emails, including links, mark bold text, and leave a failing tag empty.
diff --git a/packages/php/email-editor/docs/personalization-tags.md b/packages/php/email-editor/docs/personalization-tags.md
index e012acacaee..b90ecd3033f 100644
--- a/packages/php/email-editor/docs/personalization-tags.md
+++ b/packages/php/email-editor/docs/personalization-tags.md
@@ -129,6 +129,10 @@ The Personalizer passes the context of each replacement to the tag callback unde
| `Personalizer::RENDERING_CONTEXT_TEXT` | Plain-text output (subject, preheader, plain-text body, `<title>`) | Raw plain text. No HTML markup, no entities, no escaping. |
| `Personalizer::RENDERING_CONTEXT_HREF` | A link URL (`href`) | A raw, unescaped URL — do not pre-escape; URL escaping is applied when the attribute is written. |
+In the text rendering context the Personalizer also replaces tags in the `[text](url)` link targets of the Renderer's plain-text version (in the href rendering context), and removes a tag comment it cannot resolve, because the recipient would see it as text. A link whose whole target is a tag it cannot resolve loses the target and reads `[text]`.
+
+A tag callback that throws gives an empty value. The Personalizer logs the error through the package logger and carries on with the rest of the content.
+
If the value is a URL *component* rather than a whole URL (e.g. an email address embedded in a query string), `rawurlencode()` it in the callback — the Personalizer cannot tell the difference.
Inspecting the rendering context is optional — a callback that ignores it simply returns the same value for every destination. A tag that serves different content per destination can switch on the key:
diff --git a/packages/php/email-editor/docs/rendering.md b/packages/php/email-editor/docs/rendering.md
index d552f8f8ce1..c4c3c448873 100644
--- a/packages/php/email-editor/docs/rendering.md
+++ b/packages/php/email-editor/docs/rendering.md
@@ -90,7 +90,7 @@ public function render(
**Returns:** An array containing:
- `html`: The complete HTML email content
-- `text`: The plain text version of the email
+- `text`: The plain text version of the email. Personalization tag tokens, including those of tags not registered yet, stay in it as comments, and links whose URL is a tag keep the tag as their target. Bold text is marked as `*text*`. Personalize it with `Personalizer::RENDERING_CONTEXT_TEXT`.
**Example Usage:**
diff --git a/packages/php/email-editor/src/Engine/Renderer/class-html2text.php b/packages/php/email-editor/src/Engine/Renderer/class-html2text.php
index b37805eb894..aaed0862cb9 100644
--- a/packages/php/email-editor/src/Engine/Renderer/class-html2text.php
+++ b/packages/php/email-editor/src/Engine/Renderer/class-html2text.php
@@ -35,9 +35,11 @@ class Html2Text {
*/
public static function default_options(): array {
return array(
- 'ignore_errors' => false,
- 'drop_links' => false,
- 'char_set' => 'auto',
+ 'ignore_errors' => false,
+ 'drop_links' => false,
+ 'char_set' => 'auto',
+ 'mark_bold' => false,
+ 'tag_link_targets' => false,
);
}
@@ -567,6 +569,14 @@ class Html2Text {
$output = $node->getAttribute( 'title' );
}
+ // With tag_link_targets, a personalization tag used as the whole URL is kept as the target so it can
+ // be personalized later. A link still without text after the title fallback above is dropped rather
+ // than leaving the bare tag behind.
+ $tag_href = $options['tag_link_targets'] && $node instanceof \DOMElement ? $node->getAttribute( 'data-link-href' ) : '';
+ if ( '' !== $tag_href ) {
+ $href = $output ? $tag_href : '';
+ }
+
if ( ! $href ) {
// It doesn't link anywhere.
if ( $node instanceof \DOMElement && $node->getAttribute( 'name' ) ) {
@@ -618,6 +628,16 @@ class Html2Text {
$output .= "\n";
break;
+ case 'b':
+ case 'strong':
+ // Mark bold text as *text*, keeping the whitespace around it outside the markers.
+ $bold_text = trim( $output );
+ if ( $options['mark_bold'] && '' !== $bold_text ) {
+ $leading = substr( $output, 0, strlen( $output ) - strlen( ltrim( $output ) ) );
+ $output = $leading . '*' . $bold_text . '*' . substr( $output, strlen( rtrim( $output ) ) );
+ }
+ break;
+
case 'blockquote':
// Process quoted text for whitespace/newlines.
$output = self::process_whitespace_newlines( $output );
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 7d4eb418b56..2f0e5e539ca 100644
--- a/packages/php/email-editor/src/Engine/Renderer/class-renderer.php
+++ b/packages/php/email-editor/src/Engine/Renderer/class-renderer.php
@@ -56,13 +56,6 @@ class Renderer {
*/
private Process_Manager $process_manager;
- /**
- * Personalization tags registry
- *
- * @var Personalization_Tags_Registry
- */
- private Personalization_Tags_Registry $personalization_tags_registry;
-
/**
* Map of placeholders to full HTML comment tags for restoration.
*
@@ -81,7 +74,7 @@ class Renderer {
* @param Templates $templates Templates.
* @param Css_Inliner $css_inliner CSS Inliner.
* @param Theme_Controller $theme_controller Theme controller.
- * @param Personalization_Tags_Registry $personalization_tags_registry Personalization tags registry.
+ * @param Personalization_Tags_Registry $personalization_tags_registry Personalization tags registry. No longer used.
* @param Process_Manager $process_manager Process manager.
*/
public function __construct(
@@ -92,12 +85,13 @@ class Renderer {
Personalization_Tags_Registry $personalization_tags_registry,
Process_Manager $process_manager
) {
- $this->content_renderer = $content_renderer;
- $this->templates = $templates;
- $this->theme_controller = $theme_controller;
- $this->css_inliner = $css_inliner;
- $this->personalization_tags_registry = $personalization_tags_registry;
- $this->process_manager = $process_manager;
+ $this->content_renderer = $content_renderer;
+ $this->templates = $templates;
+ $this->theme_controller = $theme_controller;
+ $this->css_inliner = $css_inliner;
+ $this->process_manager = $process_manager;
+ // The registry is no longer needed, but integrators construct the Renderer directly, so the signature stays.
+ unset( $personalization_tags_registry );
}
/**
@@ -265,7 +259,14 @@ class Renderer {
// Preserve personalization tags by temporarily replacing them with unique placeholders.
$template = $this->preserve_personalization_tags( $template );
- $result = Html2Text::convert( (string) $template, array( 'ignore_errors' => true ) );
+ $result = Html2Text::convert(
+ (string) $template,
+ array(
+ 'ignore_errors' => true,
+ 'mark_bold' => true,
+ 'tag_link_targets' => true,
+ )
+ );
if ( ! $result ) {
return '';
}
@@ -283,38 +284,18 @@ class Renderer {
* @return string
*/
private function preserve_personalization_tags( string $template ): string {
- $all_registered_tags = $this->personalization_tags_registry->get_all();
$this->personalization_tag_placeholders = array();
$counter = 0;
- $base_tokens = array(); // All the tokens used in the email, e.g. [woocommerce/customer-username].
- $token_prefixes = array(); // All the used prefixes, e.g. woocommerce, mailpoet, etc.
- foreach ( $all_registered_tags as $tag ) {
- $token = $tag->get_token(); // E.g. [woocommerce/customer-username].
- $base_tokens[ $token ] = true;
- // Remove brackets for regex matching, escape for regex.
- $token_prefixes[] = preg_quote( substr( $token, 1, -1 ), '/' );
- }
-
- if ( empty( $token_prefixes ) ) {
- return $template;
- }
-
- // Match all of the code comments that look like a personalization tags.
- $pattern = '/<!--\[(' . implode( '|', $token_prefixes ) . ')(?:\s+[^\]]*)?\]-->/';
-
+ // Match every comment that looks like a personalization tag, registered or not: a plugin may
+ // register its tags only before personalizing, and Html2Text would drop the comment.
$template = preg_replace_callback(
- $pattern,
- function ( $matches ) use ( &$counter, $base_tokens ) {
- // $matches[1] is the token without brackets, add brackets for lookup.
- $base_token = '[' . $matches[1] . ']';
- if ( isset( $base_tokens[ $base_token ] ) ) {
- $placeholder = 'PERSONALIZATION_TAG_PLACEHOLDER_' . $counter;
- $this->personalization_tag_placeholders[ $placeholder ] = $matches[0];
- ++$counter;
- return $placeholder;
- }
- return $matches[0];
+ '/<!--\[[a-zA-Z0-9\-\/]+(?:\s+[^\]]*)?\]-->/',
+ function ( $matches ) use ( &$counter ) {
+ $placeholder = 'PERSONALIZATION_TAG_PLACEHOLDER_' . $counter;
+ $this->personalization_tag_placeholders[ $placeholder ] = $matches[0];
+ ++$counter;
+ return $placeholder;
},
$template
);
@@ -332,6 +313,8 @@ class Renderer {
if ( empty( $this->personalization_tag_placeholders ) ) {
return $text;
}
+ // Bold text made of only a tag is not marked, so an empty tag value leaves no stray "**".
+ $text = (string) preg_replace( '/\*(PERSONALIZATION_TAG_PLACEHOLDER_\d+)\*/', '$1', $text );
// Placeholders are numbered, so PERSONALIZATION_TAG_PLACEHOLDER_1 is a prefix of _10, _11 and
// so on. strtr() matches the longest key at each position and never re-scans text it has
// already replaced, so a short placeholder cannot match inside a longer one.
diff --git a/packages/php/email-editor/src/Engine/class-personalizer.php b/packages/php/email-editor/src/Engine/class-personalizer.php
index eab1adfcf99..3b7d373eb8f 100644
--- a/packages/php/email-editor/src/Engine/class-personalizer.php
+++ b/packages/php/email-editor/src/Engine/class-personalizer.php
@@ -9,6 +9,7 @@ declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine;
+use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger;
use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\HTML_Tag_Processor;
use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tag;
use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
@@ -79,16 +80,50 @@ class Personalizer {
*/
private $value_interceptor = null;
+ /**
+ * Logger for tag callbacks that fail.
+ *
+ * @var Email_Editor_Logger|null
+ */
+ private ?Email_Editor_Logger $logger;
+
/**
* Class constructor with required dependencies.
*
* @param Personalization_Tags_Registry $tags_registry Personalization tags registry.
+ * @param Email_Editor_Logger|null $logger Logger for tag callbacks that fail.
*/
- public function __construct( Personalization_Tags_Registry $tags_registry ) {
+ public function __construct( Personalization_Tags_Registry $tags_registry, ?Email_Editor_Logger $logger = null ) {
$this->tags_registry = $tags_registry;
+ $this->logger = $logger;
$this->context = array();
}
+ /**
+ * Run a tag callback. A callback that throws gives an empty value, so one broken tag does not stop the email.
+ *
+ * @param Personalization_Tag $tag The tag.
+ * @param string $rendering_context One of the RENDERING_CONTEXT_* constants.
+ * @param array<string, string> $arguments The parsed tag arguments.
+ * @return string The tag value.
+ */
+ private function get_tag_value( Personalization_Tag $tag, string $rendering_context, array $arguments ): string {
+ try {
+ return $tag->execute_callback( $this->get_callback_context( $rendering_context ), $arguments );
+ } catch ( \Throwable $e ) {
+ if ( $this->logger ) {
+ $this->logger->error(
+ 'Personalization tag callback failed',
+ array(
+ 'token' => $tag->get_token(),
+ 'error' => $e->getMessage(),
+ )
+ );
+ }
+ return '';
+ }
+ }
+
/**
* Set the context for personalization.
*
@@ -170,6 +205,10 @@ class Personalizer {
/**
* Personalize the content by replacing the personalization tags with their values.
*
+ * In the text rendering context, tags in "[text](url)" link targets are replaced too. A tag comment that
+ * cannot be resolved is removed, since the recipient would see it as text, and so is a link target that
+ * is only such a tag.
+ *
* @param string $content The content to personalize.
* @param string $rendering_context The rendering context of the content — one of the RENDERING_CONTEXT_HTML
* or RENDERING_CONTEXT_TEXT constants. Unknown values fall back to RENDERING_CONTEXT_HTML.
@@ -180,6 +219,11 @@ class Personalizer {
$rendering_context = self::RENDERING_CONTEXT_HTML;
}
+ // Link targets go first, so tag values inserted below are never scanned for link targets.
+ if ( self::RENDERING_CONTEXT_TEXT === $rendering_context ) {
+ $content = $this->personalize_text_link_targets( $content );
+ }
+
$content_processor = new HTML_Tag_Processor( $content );
while ( $content_processor->next_token() ) {
if ( $content_processor->get_token_type() === '#comment' ) {
@@ -187,10 +231,14 @@ class Personalizer {
$token = $this->parse_token( $modifiable_text );
$tag = $this->tags_registry->get_by_token( $token['token'] );
if ( ! $tag ) {
+ // In plain text the comment would be shown to the recipient, so an unresolved tag is removed.
+ if ( self::RENDERING_CONTEXT_TEXT === $rendering_context && '' !== $token['token'] ) {
+ $content_processor->replace_token( '' );
+ }
continue;
}
- $value = $tag->execute_callback( $this->get_callback_context( $rendering_context ), $token['arguments'] );
+ $value = $this->get_tag_value( $tag, $rendering_context, $token['arguments'] );
if ( self::RENDERING_CONTEXT_HTML === $rendering_context && Personalization_Tag::VALUE_TYPE_TEXT === $tag->get_value_type() ) {
$value = esc_html( $value );
}
@@ -213,7 +261,7 @@ class Personalizer {
continue;
}
- $value = $tag->execute_callback( $this->get_callback_context( self::RENDERING_CONTEXT_HREF ), $token['arguments'] );
+ $value = $this->get_tag_value( $tag, self::RENDERING_CONTEXT_HREF, $token['arguments'] );
$value = $this->replace_link_href( $href, $tag->get_token(), $value );
$value = $this->intercept_value( $value, $href, self::RENDERING_CONTEXT_HREF );
if ( '' !== $value ) {
@@ -238,6 +286,29 @@ class Personalizer {
return $content_processor->get_updated_html();
}
+ /**
+ * Replace tags in the "[text](url)" link targets of the Renderer's text version. Only the "](url)" part
+ * is matched, so link text with tags or brackets does not matter; the URL may hold one level of "(…)".
+ * A target that is only a tag that cannot be resolved is dropped, leaving "[text]", so no raw tag
+ * reaches the recipient.
+ *
+ * @param string $content Plain-text content.
+ * @return string The content with link targets personalized.
+ */
+ private function personalize_text_link_targets( string $content ): string {
+ return (string) preg_replace_callback(
+ '/\]\((\[[^\]]+\]|(?:[^()\s]++|\([^()\s]*\))++)\)/',
+ function ( array $matches ): string {
+ $url = $this->personalize_href_tokens( $matches[1] );
+ if ( null !== $url ) {
+ return '](' . $url . ')';
+ }
+ return '[' === $matches[1][0] ? ']' : $matches[0];
+ },
+ $content
+ );
+ }
+
/**
* Replace personalization tag tokens embedded in a link URL.
*
@@ -260,7 +331,7 @@ class Personalizer {
continue;
}
- $value = $tag->execute_callback( $this->get_callback_context( self::RENDERING_CONTEXT_HREF ), $token['arguments'] );
+ $value = $this->get_tag_value( $tag, self::RENDERING_CONTEXT_HREF, $token['arguments'] );
$value = $this->intercept_value( $value, $token_string, self::RENDERING_CONTEXT_HREF );
if ( '' !== $value ) {
$replacements[ $token_string ] = $value;
diff --git a/packages/php/email-editor/src/class-email-editor-container.php b/packages/php/email-editor/src/class-email-editor-container.php
index 563f937dac2..acd198d1d3d 100644
--- a/packages/php/email-editor/src/class-email-editor-container.php
+++ b/packages/php/email-editor/src/class-email-editor-container.php
@@ -234,6 +234,7 @@ class Email_Editor_Container {
function ( $container ) {
return new Personalizer(
$container->get( Personalization_Tags_Registry::class ),
+ $container->get( Email_Editor_Logger::class ),
);
}
);
diff --git a/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php b/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php
index a62237b4854..b6617f61bd1 100644
--- a/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php
+++ b/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php
@@ -722,6 +722,86 @@ class Personalizer_Test extends \Email_Editor_Integration_Test_Case {
);
}
+ /**
+ * Test that a tag token that cannot be resolved leaves nothing visible in the text rendering context,
+ * where an HTML comment is shown to the recipient as text.
+ */
+ public function testUnresolvedTagLeavesNoCommentInTextContext(): void {
+ $this->assertSame( 'Hi , welcome.', $this->personalizer->personalize_content( 'Hi <!--[other-plugin/first-name]-->, welcome.', Personalizer::RENDERING_CONTEXT_TEXT ) );
+ $this->assertSame( '<p>Hi <!--[other-plugin/first-name]-->, welcome.</p>', $this->personalizer->personalize_content( '<p>Hi <!--[other-plugin/first-name]-->, welcome.</p>' ) );
+ // A comment that is not a tag token is left alone.
+ $this->assertSame( 'Hi <!-- note -->', $this->personalizer->personalize_content( 'Hi <!-- note -->', Personalizer::RENDERING_CONTEXT_TEXT ) );
+ }
+
+ /**
+ * Test that only the link targets of the text version are personalized, not tag-like text elsewhere.
+ */
+ public function testTextContextPersonalizesOnlyLinkTargets(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'Store URL',
+ 'woocommerce/store-url',
+ 'Store',
+ function () {
+ return 'https://example.com';
+ }
+ )
+ );
+
+ $this->assertSame(
+ 'Type [woocommerce/store-url] or visit [the store](https://example.com).',
+ $this->personalizer->personalize_content( 'Type [woocommerce/store-url] or visit [the store](http://%5Bwoocommerce/store-url%5D).', Personalizer::RENDERING_CONTEXT_TEXT )
+ );
+
+ // A target with parentheses, as in Wikipedia-style URLs, is still personalized.
+ $this->assertSame(
+ 'See [the article](https://example.org/a_(b)?ref=https://example.com).',
+ $this->personalizer->personalize_content( 'See [the article](https://example.org/a_(b)?ref=[woocommerce/store-url]).', Personalizer::RENDERING_CONTEXT_TEXT )
+ );
+
+ // A tag value that looks like a link target is inserted as it is, not personalized again.
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'Name',
+ 'test/name',
+ 'Test',
+ function () {
+ return '[x](http://[woocommerce/store-url])';
+ }
+ )
+ );
+ $this->assertSame(
+ 'Hi [x](http://[woocommerce/store-url])',
+ $this->personalizer->personalize_content( 'Hi <!--[test/name]-->', Personalizer::RENDERING_CONTEXT_TEXT )
+ );
+ }
+
+ /**
+ * Test that a tag callback that throws leaves its tag empty and is logged, instead of stopping the whole email.
+ */
+ public function testThrowingCallbackLeavesTagEmptyAndIsLogged(): void {
+ $logger = $this->createMock( Email_Editor_Logger::class );
+ $logger->expects( $this->exactly( 3 ) )->method( 'error' );
+ $personalizer = new Personalizer( $this->tags_registry, $logger );
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'Broken',
+ 'test/broken',
+ 'Test',
+ function () {
+ throw new \RuntimeException( 'Order not found' );
+ }
+ )
+ );
+
+ $this->assertSame( '<p>Hi !</p>', $personalizer->personalize_content( '<p>Hi <!--[test/broken]-->!</p>' ) );
+ $this->assertSame( 'Hi !', $personalizer->personalize_content( 'Hi <!--[test/broken]-->!', Personalizer::RENDERING_CONTEXT_TEXT ) );
+ $this->assertSame(
+ '<a href="http://[test/broken]">Link</a>',
+ $personalizer->personalize_content( '<a href="http://[test/broken]">Link</a>' )
+ );
+ }
+
/**
* Test that the reserved rendering context key cannot be injected via set_context().
*/
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 66ce78c556c..509f82a9245 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
@@ -9,6 +9,7 @@ declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer;
use Automattic\WooCommerce\EmailEditor\Engine\Email_Editor;
+use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
use Automattic\WooCommerce\EmailEditor\Engine\Templates\Utils;
use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tag;
use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
@@ -68,15 +69,10 @@ class Renderer_Test extends \Email_Editor_Integration_Test_Case {
$theme_controller_mock->method( 'get_styles' )->willReturn( $styles );
$theme_controller_mock->method( 'get_layout_settings' )->willReturn( array( 'contentSize' => '660px' ) );
- // Create a mock for Personalization_Tags_Registry.
- $personalization_tags_registry_mock = $this->createMock( \Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry::class );
- $personalization_tags_registry_mock->method( 'get_all' )->willReturn( array() );
-
$this->renderer = $this->getServiceWithOverrides(
Renderer::class,
array(
- 'theme_controller' => $theme_controller_mock,
- 'personalization_tags_registry' => $personalization_tags_registry_mock,
+ 'theme_controller' => $theme_controller_mock,
)
);
@@ -441,27 +437,6 @@ class Renderer_Test extends \Email_Editor_Integration_Test_Case {
* Test that rendering preserves personalization tags.
*/
public function testItPreservesPersonalizationTags(): void {
- $registry = new \Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry(
- $this->di_container->get( \Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger::class )
- );
- $registry->register(
- new \Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tag(
- 'Customer Username',
- '[woocommerce/customer-username]',
- 'Customer',
- function () {
- return '';
- }
- )
- );
-
- $this->renderer = $this->getServiceWithOverrides(
- Renderer::class,
- array(
- 'personalization_tags_registry' => $registry,
- )
- );
-
$this->email_post->post_content = '<!-- wp:paragraph --><p><!--[woocommerce/customer-username]--><!--[woocommerce/customer-username default="john"]--></p><!-- /wp:paragraph -->';
wp_update_post(
array(
@@ -575,8 +550,8 @@ class Renderer_Test extends \Email_Editor_Integration_Test_Case {
),
'strong_emphasis' => array(
'<!-- wp:paragraph --><p>This is <strong>important</strong> and <em>emphasized</em> text.</p><!-- /wp:paragraph -->',
- 'This is important and emphasized text.',
- 'Strong and emphasis tags converted to plain text',
+ 'This is *important* and emphasized text.',
+ 'Bold text is marked with asterisks, emphasis is plain text',
),
'line_breaks' => array(
'<!-- wp:paragraph --><p>Line one<br>Line two<br/>Line three</p><!-- /wp:paragraph -->',
@@ -712,36 +687,14 @@ class Renderer_Test extends \Email_Editor_Integration_Test_Case {
* fail for an unrelated reason.
*/
public function testItRestoresMoreThanTenPersonalizationTagsInTextVersion(): void {
- $registry = new Personalization_Tags_Registry(
- $this->di_container->get( \Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger::class )
- );
-
$paragraphs = array();
$expected = array();
for ( $i = 0; $i < 14; $i++ ) {
- $token = 'renderer-test/tag-' . $i;
- $registry->register(
- new Personalization_Tag(
- 'Renderer Test Tag ' . $i,
- '[' . $token . ']',
- 'Renderer Test',
- function () use ( $i ) {
- return 'value-' . $i;
- }
- )
- );
+ $token = 'renderer-test/tag-' . $i;
$paragraphs[] = '<!-- wp:paragraph --><p>Field ' . $i . ': <!--[' . $token . ']--></p><!-- /wp:paragraph -->';
$expected[] = '<!--[' . $token . ']-->';
}
- // The renderer built in setUp() uses a registry mock with no tags, so it preserves nothing.
- $this->renderer = $this->getServiceWithOverrides(
- Renderer::class,
- array(
- 'personalization_tags_registry' => $registry,
- )
- );
-
// @phpstan-ignore-next-line PHPStan is not aware of the register_block_template function's side effects.
register_block_template(
'renderer-tests//test-email-template-personalization-tags',
@@ -768,4 +721,89 @@ class Renderer_Test extends \Email_Editor_Integration_Test_Case {
$this->assertStringContainsString( $token, $rendered['html'], "Tag {$index} was not restored in the HTML version." );
}
}
+
+ /**
+ * Render the given post content and return the text version, with a registry holding one tag for the Personalizer.
+ *
+ * @param string $content Post content.
+ * @return array{0: Personalization_Tags_Registry, 1: string}
+ */
+ private function render_with_link_tag( string $content ): array {
+ $registry = new Personalization_Tags_Registry(
+ $this->di_container->get( \Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger::class )
+ );
+ $registry->register(
+ new Personalization_Tag(
+ 'Account URL',
+ '[renderer-test/account-url]',
+ 'Renderer Test',
+ function () {
+ return 'https://shop.example/account?a=1&b=2';
+ }
+ )
+ );
+ $templates_registry = \WP_Block_Templates_Registry::get_instance();
+ if ( ! $templates_registry->is_registered( 'renderer-tests//test-email-template-plain-text' ) ) {
+ $templates_registry->register(
+ 'renderer-tests//test-email-template-plain-text',
+ array(
+ 'title' => 'Test Email Template',
+ 'description' => 'A test email template.',
+ 'content' => '<!-- wp:group --><div class="wp-block-group"><!-- wp:post-content /--></div><!-- /wp:group -->',
+ )
+ );
+ }
+
+ $rendered = $this->renderer->render_from_content( $content, 'test-email-template-plain-text', 'Subject', 'Preheader' );
+ return array( $registry, (string) $rendered['text'] );
+ }
+
+ /**
+ * Test that the text version keeps the token of a tag that is not registered, so it can still be
+ * personalized later, when the plugin has registered it, instead of disappearing.
+ */
+ public function testItKeepsTagsUnknownAtRenderTimeInTextVersion(): void {
+ list( , $text ) = $this->render_with_link_tag( '<!-- wp:paragraph --><p>Hi <!--[other-plugin/first-name]-->, welcome.</p><!-- /wp:paragraph -->' );
+ $this->assertStringContainsString( 'Hi <!--[other-plugin/first-name]-->, welcome.', $text );
+ }
+
+ /**
+ * Test that a tag used in a link URL is personalized in the text version, as it is in the HTML version.
+ */
+ public function testItPersonalizesLinkTagsInTextVersion(): void {
+ list( $registry, $text ) = $this->render_with_link_tag(
+ // The editor writes data-link-href links without an href; a button keeps href="#" next to it.
+ '<!-- wp:paragraph --><p>See <a href="http://[renderer-test/account-url]">My account</a>, <a data-link-href="[renderer-test/account-url]" contenteditable="false">Orders</a> or <a href="#" data-link-href="[renderer-test/account-url]">Returns</a>.</p><!-- /wp:paragraph -->'
+ );
+ $personalizer = new Personalizer( $registry );
+ $this->assertStringContainsString(
+ 'See [My account](https://shop.example/account?a=1&b=2), [Orders](https://shop.example/account?a=1&b=2) or [Returns](https://shop.example/account?a=1&b=2).',
+ $personalizer->personalize_content( $text, Personalizer::RENDERING_CONTEXT_TEXT )
+ );
+ }
+
+ /**
+ * Test that a link whose URL is a tag never leaves a raw tag in the personalized text version.
+ */
+ public function testItLeavesNoRawTagInTextVersionLinks(): void {
+ list( $registry, $text ) = $this->render_with_link_tag(
+ '<!-- wp:paragraph --><p>A <a data-link-href="[renderer-test/account-url note=\'x\']">Account</a>, B <a data-link-href="[other-plugin/url]">Orders</a>, C <a href="#" data-link-href="[renderer-test/account-url]"><img src="https://shop.example/logo.png"></a>, D <a data-link-href="[renderer-test/account-url]">Hi <!--[other-plugin/first-name]--></a>, E <a data-link-href="[renderer-test/account-url]">see [docs]</a>.</p><!-- /wp:paragraph -->'
+ );
+
+ $personalized = ( new Personalizer( $registry ) )->personalize_content( $text, Personalizer::RENDERING_CONTEXT_TEXT );
+ // Link text with a tag or with brackets must not stop the target from being personalized.
+ $this->assertStringContainsString(
+ 'A [Account](https://shop.example/account?a=1&b=2), B [Orders], C , D [Hi ](https://shop.example/account?a=1&b=2), E [see [docs]](https://shop.example/account?a=1&b=2).',
+ $personalized
+ );
+ $this->assertStringNotContainsString( '[other-plugin/url]', $personalized );
+ }
+
+ /**
+ * Test that bold text made of only a tag gets no bold markers, so an empty tag value leaves no stray "**".
+ */
+ public function testItDoesNotMarkBoldTagsInTextVersion(): void {
+ list( , $text ) = $this->render_with_link_tag( '<!-- wp:paragraph --><p>Username: <strong><!--[other-plugin/username]--></strong>, <strong>Bold</strong></p><!-- /wp:paragraph -->' );
+ $this->assertStringContainsString( 'Username: <!--[other-plugin/username]-->, *Bold*', $text );
+ }
}
diff --git a/packages/php/email-editor/tests/unit/Engine/Renderer/Html2Text_Test.php b/packages/php/email-editor/tests/unit/Engine/Renderer/Html2Text_Test.php
index b9dcda51e21..80aa8fb0413 100644
--- a/packages/php/email-editor/tests/unit/Engine/Renderer/Html2Text_Test.php
+++ b/packages/php/email-editor/tests/unit/Engine/Renderer/Html2Text_Test.php
@@ -85,28 +85,56 @@ class Html2Text_Test extends \Email_Editor_Unit_Test {
*/
public function html_conversion_provider(): array {
return array(
- 'horizontal_rule' => array(
+ 'horizontal_rule' => array(
'Before<hr>After',
"Before\n---------------------------------------------------------------\nAfter",
),
- 'same_url_link' => array(
+ 'same_url_link' => array(
'<a href="http://example.com">http://example.com</a>',
'http://example.com',
),
- 'mailto_link' => array(
+ 'mailto_link' => array(
'<a href="mailto:test@example.com">test@example.com</a>',
'test@example.com',
),
- 'image_without_alt' => array(
+ 'image_without_alt' => array(
'<img src="image.jpg">',
'',
),
- 'drop_links_option' => array(
+ 'drop_links_option' => array(
'<a href="http://example.com">Click here</a>',
'Click here',
array( 'drop_links' => true ),
),
- 'nested_divs_complex' => array(
+ 'bold_unmarked_by_default' => array(
+ '<p>A <strong>bold</strong> word</p>',
+ 'A bold word',
+ ),
+ 'bold_marked' => array(
+ '<p>A <strong>bold</strong> and <b> two words </b>, <strong> </strong>end</p>',
+ 'A *bold* and *two words* , end',
+ array( 'mark_bold' => true ),
+ ),
+ 'tag_link_target' => array(
+ '<a href="#" data-link-href="[woocommerce/store-url]">Shop</a>',
+ '[Shop]([woocommerce/store-url])',
+ array( 'tag_link_targets' => true ),
+ ),
+ 'tag_link_without_text' => array(
+ '<a href="#" data-link-href="[woocommerce/store-url]"><img src="logo.png"></a>',
+ '',
+ array( 'tag_link_targets' => true ),
+ ),
+ 'tag_link_with_title_label' => array(
+ '<a title="Shop" data-link-href="[woocommerce/store-url]"><img src="logo.png"></a>',
+ '[Shop]([woocommerce/store-url])',
+ array( 'tag_link_targets' => true ),
+ ),
+ 'tag_link_ignored_by_default' => array(
+ '<a href="#" data-link-href="[woocommerce/store-url]">Shop</a> and <a data-link-href="[other/x]">here</a>',
+ '[Shop](#) and here',
+ ),
+ 'nested_divs_complex' => array(
'<div>Outer<div>Middle<div>Inner</div></div></div>',
"Outer\nMiddle\nInner",
),
@@ -301,9 +329,11 @@ class Html2Text_Test extends \Email_Editor_Unit_Test {
*/
public function test_default_options(): void {
$expected_defaults = array(
- 'ignore_errors' => false,
- 'drop_links' => false,
- 'char_set' => 'auto',
+ 'ignore_errors' => false,
+ 'drop_links' => false,
+ 'char_set' => 'auto',
+ 'mark_bold' => false,
+ 'tag_link_targets' => false,
);
$this->assertEquals( $expected_defaults, Html2Text::default_options() );