Commit 96e273e5161 for woocommerce

commit 96e273e5161e5dfc139e12e88e952b30929bc56d
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date:   Thu Nov 12 12:24:52 2026 +0100

    Pass escaped argument values to HTML-type personalization tags (#70904)

    * Pass escaped argument values to HTML-typed tags in HTML content

    A tag with the default HTML value type returns HTML, and its callback
    may output an argument as it is. In HTML content such a tag now gets
    its argument values escaped for HTML, as before argument decoding was
    added; text-typed tags, plain text and link targets keep the decoded
    values.

    * Register Order Custom Field as a text tag and align docs and changelog

    HTML-typed tags now get escaped arguments in HTML content, so a field
    key such as "Customer's note" reached Order Custom Field escaped and
    found nothing. As a text tag it gets the key decoded, and its value is
    escaped like other text tags. The docs say tags that use an argument
    as data should be text tags, and the earlier changelog entry no longer
    describes the decoded values for HTML tags.

    * Describe the custom field and tag argument changes for merchants

    Stores that keep HTML in an order custom field see it as plain text
    now, so the WooCommerce entry says so. The package entry for decoded
    tag arguments has not shipped yet, so it now also covers HTML tags in
    HTML content instead of a separate entry.

    * Use a plain order in the Order Custom Field tag test

    The test needs only an order with one meta value, so it no longer
    creates line items and other fixtures it doesn't check.

diff --git a/packages/php/email-editor/changelog/fix-wooplug-8121-tag-attribute-entities b/packages/php/email-editor/changelog/fix-wooplug-8121-tag-attribute-entities
index e80d7a53895..470a4e9d87c 100644
--- a/packages/php/email-editor/changelog/fix-wooplug-8121-tag-attribute-entities
+++ b/packages/php/email-editor/changelog/fix-wooplug-8121-tag-attribute-entities
@@ -1,4 +1,4 @@
 Significance: patch
 Type: fix

-Pass tag attribute values with quotes, ampersands or brackets correctly; tag callbacks now get them decoded and must escape them.
+Pass tag attribute values with quotes, ampersands or brackets correctly. HTML tags in HTML content still get them escaped for HTML.
diff --git a/packages/php/email-editor/docs/personalization-tags.md b/packages/php/email-editor/docs/personalization-tags.md
index c0b34939d83..86078971a5a 100644
--- a/packages/php/email-editor/docs/personalization-tags.md
+++ b/packages/php/email-editor/docs/personalization-tags.md
@@ -81,7 +81,7 @@ When selected from a modal, the tag is placed into a text block displayed in a s

 ## Format

-Personalization Tags use HTML comment syntax with a specific format: `<!--[token-name attributes]-->`. The token name follows a namespaced pattern (e.g., `my-plugin/tag-name`) and can include attributes for configuration. Attribute values may be quoted with double or single quotes and may contain `]`. HTML entities in values (such as `&#039;`, which `esc_attr()` adds) are decoded before the callback receives them, so a callback that outputs an argument in HTML must escape it. This format ensures safe fallback behavior in browsers and provides reliable parsing capabilities.
+Personalization Tags use HTML comment syntax with a specific format: `<!--[token-name attributes]-->`. The token name follows a namespaced pattern (e.g., `my-plugin/tag-name`) and can include attributes for configuration. Attribute values may be quoted with double or single quotes and may contain `]`. HTML entities in values (such as `&#039;`, which `esc_attr()` adds) are decoded before the callback receives them. In HTML content, a tag with the default `VALUE_TYPE_HTML` receives the values escaped for HTML instead, so it can output an argument as it is. A tag that uses an argument as data, such as a key, a date format or a URL, should declare `VALUE_TYPE_TEXT`, or decode the value itself. This format ensures safe fallback behavior in browsers and provides reliable parsing capabilities.

 **Example usage in a paragraph block:**

diff --git a/packages/php/email-editor/src/Engine/class-personalizer.php b/packages/php/email-editor/src/Engine/class-personalizer.php
index 1fb16d62dde..607f5642bc2 100644
--- a/packages/php/email-editor/src/Engine/class-personalizer.php
+++ b/packages/php/email-editor/src/Engine/class-personalizer.php
@@ -249,7 +249,12 @@ class Personalizer {
 					continue;
 				}

-				$value = $this->get_tag_value( $tag, $rendering_context, $token['arguments'] );
+				$arguments = $token['arguments'];
+				if ( self::RENDERING_CONTEXT_HTML === $rendering_context && Personalization_Tag::VALUE_TYPE_HTML === $tag->get_value_type() ) {
+					// An HTML-typed callback may output an argument as it is, so in HTML content it gets the values escaped for HTML.
+					$arguments = array_map( 'esc_html', $arguments );
+				}
+				$value = $this->get_tag_value( $tag, $rendering_context, $arguments );
 				if ( self::RENDERING_CONTEXT_HTML === $rendering_context && Personalization_Tag::VALUE_TYPE_TEXT === $tag->get_value_type() ) {
 					$value = esc_html( $value );
 				}
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 67f5ab0a160..31f984702d3 100644
--- a/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php
+++ b/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php
@@ -833,7 +833,11 @@ class Personalizer_Test extends \Email_Editor_Integration_Test_Case {
 				function ( $context, $args ) use ( &$received ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- The $context parameter is not used in this test.
 					$received[] = $args['note'] ?? '';
 					return 'https://example.com/value';
-				}
+				},
+				array(),
+				null,
+				array(),
+				Personalization_Tag::VALUE_TYPE_TEXT
 			)
 		);

@@ -841,6 +845,54 @@ class Personalizer_Test extends \Email_Editor_Integration_Test_Case {
 		$this->assertSame( array( 'O\'Brien "Bob" & co' ), $received );
 	}

+	/**
+	 * Test that an html value type tag still gets decoded argument values in link targets.
+	 */
+	public function testHtmlValueTypeTagGetsDecodedArgumentsInLinks(): void {
+		$received = array();
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'link',
+				'test/link',
+				'Test',
+				function ( $context, $args ) use ( &$received ) {
+					unset( $context );
+					$received[] = $args['note'] ?? '';
+					return 'https://example.com/value';
+				}
+			)
+		);
+
+		$this->personalizer->personalize_content( '<a data-link-href="[test/link note=&quot;O&amp;#039;Brien &amp;amp; co&quot;]">Link</a>' );
+		$this->assertSame( array( "O'Brien & co" ), $received );
+	}
+
+	/**
+	 * Test that an html value type tag gets its argument values escaped for HTML in HTML content,
+	 * so a callback that outputs an argument as it is shows it as text, and decoded elsewhere.
+	 */
+	public function testHtmlValueTypeTagGetsEscapedArgumentsInHtmlContent(): void {
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'echo',
+				'test/echo',
+				'Test',
+				function ( $context, $args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- The $context parameter is not used in this test.
+					return $args['note'] ?? '';
+				}
+			)
+		);
+
+		$this->assertSame(
+			'<p>&lt;b&gt;x&lt;/b&gt; O&#039;Brien</p>',
+			$this->personalizer->personalize_content( '<p><!--[test/echo note="&lt;b&gt;x&lt;/b&gt;"]--> <!--[test/echo note="O&#039;Brien"]--></p>' )
+		);
+		$this->assertSame(
+			"<b>x</b> O'Brien",
+			$this->personalizer->personalize_content( '<!--[test/echo note="&lt;b&gt;x&lt;/b&gt;"]--> <!--[test/echo note="O&#039;Brien"]-->', Personalizer::RENDERING_CONTEXT_TEXT )
+		);
+	}
+
 	/**
 	 * Test that a "]" inside a quoted argument value does not end a tag inside a link early.
 	 */
diff --git a/plugins/woocommerce/changelog/fix-order-custom-field-tag-text b/plugins/woocommerce/changelog/fix-order-custom-field-tag-text
new file mode 100644
index 00000000000..34bb4fe67f1
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-order-custom-field-tag-text
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix the Order Custom Field personalization tag for field keys with apostrophes; HTML in field values now shows as plain text.
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTags/OrderTagsProvider.php b/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTags/OrderTagsProvider.php
index bce12ac0137..08c6063e064 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTags/OrderTagsProvider.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTags/OrderTagsProvider.php
@@ -339,6 +339,7 @@ class OrderTagsProvider extends AbstractTagProvider {
 				),
 				null,
 				array( Integration::EMAIL_POST_TYPE ),
+				Personalization_Tag::VALUE_TYPE_TEXT,
 			)
 		);
 	}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PersonalizationTags/OrderTagsProviderTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PersonalizationTags/OrderTagsProviderTest.php
index df02d4a5ea7..afcc6697e0d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PersonalizationTags/OrderTagsProviderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PersonalizationTags/OrderTagsProviderTest.php
@@ -86,4 +86,19 @@ class OrderTagsProviderTest extends WC_Unit_Test_Case {

 		$this->assertSame( '<p>&lt;mark&gt;2024&lt;/mark&gt;</p>', $html );
 	}
+
+	/**
+	 * @testdox The order custom field tag finds a field whose key has an apostrophe and shows its value as text.
+	 */
+	public function test_order_custom_field_tag_reads_key_with_apostrophe_and_shows_text(): void {
+		$order = wc_create_order();
+		$order->update_meta_data( "Customer's note", '<b>Leave</b> at the door' );
+		$order->save();
+		$personalizer = new Personalizer( $this->registry );
+		$personalizer->set_context( array( 'order' => $order ) );
+
+		$html = $personalizer->personalize_content( '<p><!--[woocommerce/order-custom-field key="Customer&#039;s note"]--></p>' );
+
+		$this->assertSame( '<p>&lt;b&gt;Leave&lt;/b&gt; at the door</p>', $html );
+	}
 }