',
+ ),
+ );
+ }
+}
From afc63eb9d69e03978686bee3b41b5a964a034244 Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Mon, 2 Feb 2026 16:10:11 +0100
Subject: [PATCH 02/66] DROPME: Collect examples
---
templating-examples.php | 205 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 205 insertions(+)
create mode 100644 templating-examples.php
diff --git a/templating-examples.php b/templating-examples.php
new file mode 100644
index 0000000000000..2c9e4e80b83f3
--- /dev/null
+++ b/templating-examples.php
@@ -0,0 +1,205 @@
+Error: This is not a valid feed template.' ), '', array( 'response' => 404 ) );
+
+// src/wp-includes/functions.php:1844-1845
+// Link with placeholder inside translation
+$wpdb->error = sprintf(
+ /* translators: %s: Database repair URL. */
+ __( 'One or more database tables are unavailable. The database may need to be repaired.' ),
+ 'maint/repair.php?referrer=is_blog_installed'
+);
+
+// src/wp-admin/edit-form-advanced.php:185
+// HTML wrapper injected as sprintf parameter
+$messages['post'][9] = sprintf( __( 'Post scheduled for: %s.' ), '' . $scheduled_date . '' ) . $scheduled_post_link_html;
+
+// src/wp-admin/revision.php:145-147
+// Multiple strong tags for emphasis in help text
+$revisions_overview .= '
' . __( 'To navigate between revisions, drag the slider handle left or right or use the Previous or Next buttons.' ) . '
';
+$revisions_overview .= '
' . __( 'Compare two different revisions by selecting the “Compare any two revisions” box to the side.' ) . '
';
+$revisions_overview .= '
' . __( 'To restore a revision, click Restore This Revision.' ) . '
';
+
+// src/wp-includes/theme.php:978-979
+// Context translation (_x) with HTML error prefix
+return new WP_Error(
+ 'theme_wp_php_incompatible',
+ sprintf(
+ /* translators: %s: Theme name. */
+ _x( 'Error: Current WordPress and PHP versions do not meet minimum requirements for %s.', 'theme' ),
+ $theme->display( 'Name' )
+ )
+);
+
+// src/wp-includes/widgets/class-wp-widget-text.php:542
+// Complex: _e() with embedded link and HTML entities
+_e( 'Did you know there is a “Custom HTML” widget now? You can find it by pressing the “Add a Widget” button and searching for “HTML”. Check it out to add some custom code to your site!' );
+
+// src/wp-includes/blocks/comments-title.php:29
+// HTML entities (curly quotes) in translation
+$post_title = sprintf( __( '“%s”' ), get_the_title() );
+
+// src/wp-includes/blocks/latest-posts.php:164-166
+// Complex nested HTML with multiple escaped placeholders
+$trimmed_excerpt .= sprintf(
+ /* translators: 1: A URL to a post, 2: Hidden accessibility text: Post title */
+ __( '… Read more: %2$s' ),
+ esc_url( $post_link ),
+ esc_html( $title )
+);
+
+// src/wp-admin/includes/class-plugin-upgrader.php:60
+// HTML span wrapping another placeholder (double sprintf)
+$this->strings['downloading_package'] = sprintf( __( 'Downloading update from %s…' ), '%s' );
+
+// src/wp-admin/includes/privacy-tools.php:404
+// Code tag wrapping technical reference
+sprintf( __( 'The %s post meta must be an array.' ), '_export_data_grouped' );
+
+// src/wp-admin/widgets.php:24
+// Full paragraph with embedded documentation link
+wp_die( __( 'The theme you are currently using is not widget-aware, meaning that it has no sidebars that you are able to change. For information on making your theme widget-aware, please follow these instructions.' ) );
+
+// src/wp-admin/revision.php:158
+// Link in translation without placeholders
+$revisions_sidebar .= '
';
$this->assertEqualHTML( $expected, $result );
}
- public function test_4() {
+ public function test_nested_template_replacement() {
$template_string = '
Hello, %html>';
$replacements = array( 'html' => T::from( 'Alice & Bob' ) );
@@ -114,10 +126,24 @@ public function test_attr_no_recursive_replacement() {
$this->assertEqualHTML( $expected, $result );
}
- public function test_attr() {
+ public function test_replaces_attribute_values() {
$template_string = '';
$replacements = array(
'n' => 'the name',
+ 'c' => 'the content',
+ );
+
+ $t = T::from( $template_string );
+ $result = $t->render( $replacements );
+ $this->assertSame( $result, T::sprintf( $template_string, $replacements ) );
+
+ $expected = '';
+ $this->assertEqualHTML( $expected, $result );
+ }
+
+ public function test_escapes_attribute_values() {
+ $template_string = '';
+ $replacements = array(
'c' => 'the "content" & whatever else',
);
@@ -125,10 +151,7 @@ public function test_attr() {
$result = $t->render( $replacements );
$this->assertSame( $result, T::sprintf( $template_string, $replacements ) );
- $expected =
- <<<'HTML'
-
- HTML;
+ $expected = '';
$this->assertEqualHTML( $expected, $result );
}
From a451ed4482ecd906a09498ed448c76611bbfd7d0 Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Wed, 4 Feb 2026 15:35:34 +0100
Subject: [PATCH 18/66] Improve test structure and names
---
.../phpunit/tests/html-api/wpHtmlTemplate.php | 246 +++++++++---------
1 file changed, 128 insertions(+), 118 deletions(-)
diff --git a/tests/phpunit/tests/html-api/wpHtmlTemplate.php b/tests/phpunit/tests/html-api/wpHtmlTemplate.php
index fbf4282194330..ab01e33b64cce 100644
--- a/tests/phpunit/tests/html-api/wpHtmlTemplate.php
+++ b/tests/phpunit/tests/html-api/wpHtmlTemplate.php
@@ -16,77 +16,15 @@
use WP_HTML_Template as T;
class Tests_HtmlApi_WpHtmlTemplate extends WP_UnitTestCase {
- public function test_basic_text_replacement() {
- $t = T::from( '
',
+ '',
),
);
}
@@ -246,6 +237,7 @@ public static function data_template() {
* @dataProvider data_real_world_examples
*
* @ticket 60229
+ *
* @covers ::sprintf
*/
public function test_real_world_examples( string $template_string, array $replacements, string $expected ) {
@@ -510,7 +502,16 @@ public static function data_real_world_examples() {
);
}
- public function test_multi_replace() {
+ /**
+ * Verifies nested templates work correctly in a definition list.
+ *
+ * @ticket 60229
+ *
+ * @covers ::from
+ * @covers ::render
+ * @covers ::sprintf
+ */
+ public function test_nested_templates_in_definition_list() {
$row_template_string = "
%term>
\n
%definition>
";
// @todo It should be possible to produce templates from an original.
@@ -557,7 +558,16 @@ public function test_multi_replace() {
$this->assertEqualHTML( $expected, $result );
}
- public function test_multi_replace_table() {
+ /**
+ * Verifies table templates are not yet supported.
+ *
+ * @ticket 60229
+ *
+ * @covers ::from
+ * @covers ::render
+ * @covers ::sprintf
+ */
+ public function test_table_templates_not_yet_supported() {
$this->markTestSkipped( 'IN TABLE templates are not supported yet.' );
$header_tpl = WP_HTML_Template::from(
'
% ID >
% name >
% value >
% link >',
From 48ff31924ae16b99329e2f3ee6da1b45fea78ec4 Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Wed, 4 Feb 2026 15:39:44 +0100
Subject: [PATCH 19/66] Refactor WP_HTML_Template to use composition over
inheritance
Replace `extends WP_HTML_Tag_Processor` with an internal anonymous class
that provides accessor methods for the protected properties needed during
template rendering. This improves encapsulation by not exposing the tag
processor's public API on the template class.
---
.../html-api/class-wp-html-template.php | 149 ++++++++++++------
1 file changed, 99 insertions(+), 50 deletions(-)
diff --git a/src/wp-includes/html-api/class-wp-html-template.php b/src/wp-includes/html-api/class-wp-html-template.php
index 08ce503043b4f..99ef3c6fcaeb4 100644
--- a/src/wp-includes/html-api/class-wp-html-template.php
+++ b/src/wp-includes/html-api/class-wp-html-template.php
@@ -7,7 +7,7 @@
* @since 7.0.0
*/
-class WP_HTML_Template extends WP_HTML_Tag_Processor {
+class WP_HTML_Template {
/**
* The template string.
*
@@ -76,7 +76,44 @@ public function render( ?array $replacements = null ) {
return WP_HTML_Processor::normalize( $this->template_string ) ?? $this->template_string;
}
- $processor = new WP_HTML_Tag_Processor( $this->template_string );
+ $processor = new class( $this->template_string ) extends WP_HTML_Tag_Processor {
+ /**
+ * Returns the HTML string being processed.
+ *
+ * @return string The HTML string.
+ */
+ public function get_html(): string {
+ return $this->html;
+ }
+
+ /**
+ * Returns a bookmark by name.
+ *
+ * @param string $name The bookmark name.
+ * @return WP_HTML_Span|null The bookmark span, or null if not found.
+ */
+ public function get_bookmark( string $name ) {
+ return $this->bookmarks[ $name ] ?? null;
+ }
+
+ /**
+ * Returns the tag attributes array.
+ *
+ * @return array The attributes array.
+ */
+ public function get_tag_attributes(): array {
+ return $this->attributes;
+ }
+
+ /**
+ * Adds a lexical update.
+ *
+ * @param WP_HTML_Text_Replacement $update The text replacement to add.
+ */
+ public function add_lexical_update( WP_HTML_Text_Replacement $update ): void {
+ $this->lexical_updates[] = $update;
+ }
+ };
$error_occurred = false;
while ( $processor->next_token() ) {
@@ -87,14 +124,16 @@ public function render( ?array $replacements = null ) {
*/
case '#text':
$processor->set_bookmark( 'text' );
- $mark = $processor->bookmarks['text'] ?? null;
+ $mark = $processor->get_bookmark( 'text' );
assert( null !== $mark );
$normalized = $processor->serialize_token();
- if ( 0 !== substr_compare( $processor->html, $normalized, $mark->start, min( $mark->length, strlen( $normalized ) ) ) ) {
- $processor->lexical_updates[] = new WP_HTML_Text_Replacement(
- $mark->start,
- $mark->length,
- $normalized
+ if ( 0 !== substr_compare( $processor->get_html(), $normalized, $mark->start, min( $mark->length, strlen( $normalized ) ) ) ) {
+ $processor->add_lexical_update(
+ new WP_HTML_Text_Replacement(
+ $mark->start,
+ $mark->length,
+ $normalized
+ )
);
}
break;
@@ -102,16 +141,17 @@ public function render( ?array $replacements = null ) {
case '#funky-comment':
// Does it look like a placeholder?
$processor->set_bookmark( 'placeholder' );
- $mark = $processor->bookmarks['placeholder'] ?? null;
+ $mark = $processor->get_bookmark( 'placeholder' );
assert( null !== $mark );
// A funky comment looks at least like %…>
$start = $mark->start;
$length = $mark->length;
+ $html = $processor->get_html();
// This is not the funky comment we're looking for.
- if ( $length < 5 || ! $processor->html[ $start + 2 ] === '%' ) {
+ if ( $length < 5 || ! $html[ $start + 2 ] === '%' ) {
break;
}
- $placeholder = trim( \substr( $processor->html, $start + 3, $length - 4 ), " \t\n\r\f" );
+ $placeholder = trim( \substr( $html, $start + 3, $length - 4 ), " \t\n\r\f" );
// Valid placeholders match `/a-z0-9_-/i`.
if ( \strlen( $placeholder ) !== \strspn( $placeholder, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-' ) ) {
@@ -120,25 +160,29 @@ public function render( ?array $replacements = null ) {
$replacement = $this->get_replacement( $placeholder );
if ( \is_string( $replacement ) ) {
- $processor->lexical_updates[] = new WP_HTML_Text_Replacement(
- $start,
- $length,
- strtr(
- $replacement,
- array(
- '<' => '<',
- '>' => '>',
- "'" => ''',
- '"' => '"',
- '&' => '&',
+ $processor->add_lexical_update(
+ new WP_HTML_Text_Replacement(
+ $start,
+ $length,
+ strtr(
+ $replacement,
+ array(
+ '<' => '<',
+ '>' => '>',
+ "'" => ''',
+ '"' => '"',
+ '&' => '&',
+ )
)
)
);
} elseif ( $replacement instanceof WP_HTML_Template ) {
- $processor->lexical_updates[] = new WP_HTML_Text_Replacement(
- $start,
- $length,
- $replacement->render()
+ $processor->add_lexical_update(
+ new WP_HTML_Text_Replacement(
+ $start,
+ $length,
+ $replacement->render()
+ )
);
}
break;
@@ -148,7 +192,8 @@ public function render( ?array $replacements = null ) {
break;
}
- foreach ( $processor->attributes as $attribute ) {
+ $html = $processor->get_html();
+ foreach ( $processor->get_tag_attributes() as $attribute ) {
// Boolean attributes cannot contain placeholders.
if ( $attribute->is_true ) {
continue;
@@ -168,7 +213,7 @@ public function render( ?array $replacements = null ) {
while (
1 === preg_match(
'#%[ \\t\\r\\f\\n]*([a-z0-9_-]+)[ \\t\\r\\f\\n]*>#i',
- $processor->html,
+ $html,
$matches,
PREG_OFFSET_CAPTURE,
$offset
@@ -181,32 +226,36 @@ public function render( ?array $replacements = null ) {
$match_length = strlen( $matches[0][0] );
// Capture and clean the preceding attribute text.
- $processor->lexical_updates[] = new WP_HTML_Text_Replacement(
- $last_offset,
- $match_at - $last_offset,
- strtr(
- substr( $processor->html, $last_offset, $match_at - $last_offset ),
- array(
- '<' => '<',
- '>' => '>',
- "'" => ''',
- '"' => '"',
- '&' => '&',
+ $processor->add_lexical_update(
+ new WP_HTML_Text_Replacement(
+ $last_offset,
+ $match_at - $last_offset,
+ strtr(
+ substr( $html, $last_offset, $match_at - $last_offset ),
+ array(
+ '<' => '<',
+ '>' => '>',
+ "'" => ''',
+ '"' => '"',
+ '&' => '&',
+ )
)
)
);
- $processor->lexical_updates[] = new WP_HTML_Text_Replacement(
- $match_at,
- strlen( $matches[0][0] ),
- strtr(
- $replacement,
- array(
- '<' => '<',
- '>' => '>',
- "'" => ''',
- '"' => '"',
- '&' => '&',
+ $processor->add_lexical_update(
+ new WP_HTML_Text_Replacement(
+ $match_at,
+ strlen( $matches[0][0] ),
+ strtr(
+ $replacement,
+ array(
+ '<' => '<',
+ '>' => '>',
+ "'" => ''',
+ '"' => '"',
+ '&' => '&',
+ )
)
)
);
From e04d7c052bc1c703bd1e6339b11f20c9b070ac24 Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Wed, 4 Feb 2026 15:42:31 +0100
Subject: [PATCH 20/66] Remove unused preg_attribute_replace_callback method
from WP_HTML_Template
---
.../html-api/class-wp-html-template.php | 24 -------------------
1 file changed, 24 deletions(-)
diff --git a/src/wp-includes/html-api/class-wp-html-template.php b/src/wp-includes/html-api/class-wp-html-template.php
index 99ef3c6fcaeb4..0db4455e55a5d 100644
--- a/src/wp-includes/html-api/class-wp-html-template.php
+++ b/src/wp-includes/html-api/class-wp-html-template.php
@@ -281,30 +281,6 @@ public function add_lexical_update( WP_HTML_Text_Replacement $update ): void {
return WP_HTML_Processor::normalize( $html ) ?? $html;
}
- private function preg_attribute_replace_callback( $matches ) {
- $key = $matches[1];
-
- $replacement = $this->get_replacement( $key );
-
- // Keep placeholder if no replacement found.
- if ( null === $replacement ) {
- return $matches[0];
- }
-
- // HTML cannot be embedded in attribute values.
- if ( $replacement instanceof self ) {
- _doing_it_wrong(
- __METHOD__,
- // @todo improve this message, include the placeholder in the string.
- __( 'Attribute values cannot contain HTML. Use a plain string.' ),
- '7.0.0'
- );
- return '';
- }
-
- return $replacement;
- }
-
/**
* Get the replacement value for a placeholder key.
*
From 65f62a9addc2a250d5d7339e80ba5c7790cec457 Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Wed, 4 Feb 2026 16:26:27 +0100
Subject: [PATCH 21/66] Add more tests
---
.../phpunit/tests/html-api/wpHtmlTemplate.php | 151 ++++++++++++++++++
1 file changed, 151 insertions(+)
diff --git a/tests/phpunit/tests/html-api/wpHtmlTemplate.php b/tests/phpunit/tests/html-api/wpHtmlTemplate.php
index ab01e33b64cce..ef217c3d3e6e8 100644
--- a/tests/phpunit/tests/html-api/wpHtmlTemplate.php
+++ b/tests/phpunit/tests/html-api/wpHtmlTemplate.php
@@ -618,4 +618,155 @@ public function test_table_templates_not_yet_supported() {
HTML;
$this->assertEqualHTML( $expected, $result );
}
+
+ /**
+ * Verifies that attributes are replaced in atomic elements (SCRIPT, STYLE, TITLE).
+ *
+ * These elements have special parsing rules that skip their content,
+ * but attributes should still be processed normally.
+ *
+ * @ticket 60229
+ *
+ * @dataProvider data_atomic_element_attributes
+ *
+ * @covers ::from
+ * @covers ::render
+ * @covers ::sprintf
+ */
+ public function test_atomic_element_attributes_are_replaced( string $template_string, array $replacements, string $expected ) {
+ $t = T::from( $template_string );
+ $result = $t->render( $replacements );
+ $this->assertSame( $result, T::sprintf( $template_string, $replacements ) );
+ $this->assertSame( $expected, $result );
+ }
+
+ public static function data_atomic_element_attributes() {
+ return array(
+ 'SCRIPT element attributes' => array(
+ '',
+ array( 'src' => '/js/app.js' ),
+ '',
+ ),
+
+ 'STYLE element attributes' => array(
+ '',
+ array( 'media' => 'screen' ),
+ '',
+ ),
+
+ 'TITLE element attributes' => array(
+ 'Page Title',
+ array( 'lang' => 'en' ),
+ 'Page Title',
+ ),
+
+ 'TEXTAREA element attributes' => array(
+ '',
+ array( 'name' => 'my-textarea' ),
+ '',
+ ),
+ );
+ }
+
+ /**
+ * Verifies content placeholder behavior in elements with special parsing.
+ *
+ * - RAWTEXT elements (SCRIPT, STYLE): Content is skipped, placeholders preserved literally.
+ * - RCDATA elements (TITLE, TEXTAREA): Content is processed but placeholders are not
+ * recognized - they're treated as literal text and HTML-escaped.
+ *
+ * @ticket 60229
+ *
+ * @dataProvider data_atomic_element_content_placeholders
+ *
+ * @covers ::from
+ * @covers ::render
+ * @covers ::sprintf
+ */
+ public function test_special_element_content_placeholder_behavior( string $template_string, array $replacements, string $expected ) {
+ $t = T::from( $template_string );
+ $result = $t->render( $replacements );
+ $this->assertSame( $result, T::sprintf( $template_string, $replacements ) );
+ $this->assertSame( $expected, $result );
+ }
+
+ public static function data_atomic_element_content_placeholders() {
+ return array(
+ // RAWTEXT elements (SCRIPT, STYLE): Content is truly skipped, placeholders preserved literally.
+ 'SCRIPT content placeholder ignored' => array(
+ '',
+ array( 'name' => 'SHOULD NOT APPEAR' ),
+ '',
+ ),
+
+ 'STYLE content placeholder ignored' => array(
+ '',
+ array( 'content' => 'SHOULD NOT APPEAR' ),
+ '',
+ ),
+
+ // RCDATA elements (TITLE, TEXTAREA): Content is processed but placeholder
+ // patterns are not recognized - they're treated as literal text and escaped.
+ 'TITLE content placeholder not recognized' => array(
+ 'Hello %name>',
+ array( 'name' => 'SHOULD NOT APPEAR' ),
+ 'Hello </%name>',
+ ),
+
+ 'TEXTAREA content placeholder not recognized' => array(
+ '',
+ array( 'placeholder' => 'SHOULD NOT APPEAR' ),
+ '',
+ ),
+ );
+ }
+
+ /**
+ * Verifies leading newline behavior in PRE elements.
+ *
+ * HTML5 specifies that a single leading newline immediately after the
+ *
start tag is ignored. This test documents the template behavior.
+ *
+ * @ticket 60229
+ *
+ * @dataProvider data_pre_element_leading_newline
+ *
+ * @covers ::from
+ * @covers ::render
+ * @covers ::sprintf
+ */
+ public function test_pre_element_leading_newline_behavior( string $template_string, array $replacements, string $expected ) {
+ $t = T::from( $template_string );
+ $result = $t->render( $replacements );
+ $this->assertSame( $result, T::sprintf( $template_string, $replacements ) );
+ $this->assertSame( $expected, $result );
+ }
+
+ public static function data_pre_element_leading_newline() {
+ return array(
+ 'PRE without newline' => array(
+ "
%code>
",
+ array( 'code' => "line1\nline2"),
+ "
line1\nline2
",
+ ),
+
+ 'PRE with newline' => array(
+ "
\n%code>
",
+ array( 'code' => "line1\nline2"),
+ "
line1\nline2
",
+ ),
+
+ 'PRE with newline in replacement' => array(
+ "
\n%code>
",
+ array( 'code' => "line1\nline2"),
+ "
line1\nline2
",
+ ),
+
+ 'PRE with newline and newline in replacement' => array(
+ "
",
),
+ /*
+ * This may seem wrong, but the template is processed like HTML. The leading newline
+ * is removed.
+ * The newline inside the replacement is rendered as HTML and is also removed.
+ *
+ * The correct way to do this for a PRE tag is:
+ * - Leading newline in template is irrelevant.
+ * - Replacement must include an extra newline to lead with a newline in the output.
+ *
+ * See the next case.
+ */
'PRE with newline and newline in replacement' => array(
"
\n%code>
",
- array( 'code' => "\nline1\nline2"),
- "
\nline1\nline2
",
+ array( 'code' => "\nline1\nline2" ),
+ "
\n\nline1\nline2
",
+ ),
+
+ 'PRE with newline and double-newline in replacement' => array(
+ "
\n%code>
",
+ array( 'code' => "\nline1\nline2" ),
+ "
\n\nline1\nline2
",
),
);
}
From 0bda31b0b9a8076f3f4e6f34305495b4dfd79c52 Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Fri, 6 Feb 2026 16:45:53 +0100
Subject: [PATCH 35/66] Update PRE leading newline test
---
tests/phpunit/tests/html-api/wpHtmlTemplate.php | 17 -----------------
1 file changed, 17 deletions(-)
diff --git a/tests/phpunit/tests/html-api/wpHtmlTemplate.php b/tests/phpunit/tests/html-api/wpHtmlTemplate.php
index af32b7efd6204..38a39fb899019 100644
--- a/tests/phpunit/tests/html-api/wpHtmlTemplate.php
+++ b/tests/phpunit/tests/html-api/wpHtmlTemplate.php
@@ -749,28 +749,11 @@ public static function data_pre_element_leading_newline() {
"
line1\nline2
",
),
- /*
- * This may seem wrong, but the template is processed like HTML. The leading newline
- * is removed.
- * The newline inside the replacement is rendered as HTML and is also removed.
- *
- * The correct way to do this for a PRE tag is:
- * - Leading newline in template is irrelevant.
- * - Replacement must include an extra newline to lead with a newline in the output.
- *
- * See the next case.
- */
'PRE with newline and newline in replacement' => array(
"
\n%code>
",
array( 'code' => "\nline1\nline2" ),
"
\n\nline1\nline2
",
),
-
- 'PRE with newline and double-newline in replacement' => array(
- "
\n%code>
",
- array( 'code' => "\nline1\nline2" ),
- "
\n\nline1\nline2
",
- ),
);
}
From d99bb22572f4397c5bf2ecf0bc42020f5275a195 Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Fri, 6 Feb 2026 16:53:12 +0100
Subject: [PATCH 36/66] Add more test cases
---
tests/phpunit/tests/html-api/wpHtmlTemplate.php | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/tests/phpunit/tests/html-api/wpHtmlTemplate.php b/tests/phpunit/tests/html-api/wpHtmlTemplate.php
index 38a39fb899019..12459087f8644 100644
--- a/tests/phpunit/tests/html-api/wpHtmlTemplate.php
+++ b/tests/phpunit/tests/html-api/wpHtmlTemplate.php
@@ -70,14 +70,14 @@ public function test_replaces_only_in_first_duplicate_attribute() {
* @covers ::render
*/
public function test_attribute_replacement_is_not_recursive() {
- $template_string = '';
+ $template_string = '
",
),
@@ -792,7 +792,12 @@ public function test_bind_warns_on_missing_key() {
*/
public function test_bind_warns_on_unused_key() {
$template = T::from( '
%name>
' );
- $template->bind( array( 'name' => 'Alice', 'extra' => 'ignored' ) );
+ $template->bind(
+ array(
+ 'name' => 'Alice',
+ 'extra' => 'ignored',
+ )
+ );
}
/**
From c275c8626c6b924d1d2e31c7dfdc5891f240efcb Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Mon, 9 Feb 2026 13:33:21 +0100
Subject: [PATCH 51/66] Document $replacements
---
src/wp-includes/html-api/class-wp-html-template.php | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/src/wp-includes/html-api/class-wp-html-template.php b/src/wp-includes/html-api/class-wp-html-template.php
index 033ea88e83a77..5531d9fad6ce0 100644
--- a/src/wp-includes/html-api/class-wp-html-template.php
+++ b/src/wp-includes/html-api/class-wp-html-template.php
@@ -17,7 +17,14 @@ class WP_HTML_Template {
*/
private string $template_string;
- private array $replacements = array();
+ /**
+ * The replacement values for placeholders.
+ *
+ * @since 7.0.0
+ *
+ * @var array|null
+ */
+ private ?array $replacements = null;
/**
* Whether the template has been compiled.
From d6c1c24a7f8a83c351ddea390b74ef9b22f68d65 Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Mon, 9 Feb 2026 13:46:50 +0100
Subject: [PATCH 52/66] Use WP_HTML_Text_Replacement class
---
.../html-api/class-wp-html-template.php | 42 +++++++++----------
1 file changed, 21 insertions(+), 21 deletions(-)
diff --git a/src/wp-includes/html-api/class-wp-html-template.php b/src/wp-includes/html-api/class-wp-html-template.php
index 5531d9fad6ce0..fdd2824236b94 100644
--- a/src/wp-includes/html-api/class-wp-html-template.php
+++ b/src/wp-includes/html-api/class-wp-html-template.php
@@ -46,7 +46,7 @@ class WP_HTML_Template {
* ['start' => int, 'length' => int, 'placeholder' => string, 'context' => 'text'|'attribute']
*
* @since 7.0.0
- * @var array
+ * @var (array{'start': int, 'length': int, 'placeholder': string, 'context': 'text'|'attribute'}|WP_HTML_Text_Replacement)[]
*/
private array $edits = array();
@@ -150,10 +150,10 @@ public function get_tag_attributes(): array {
}
$normalized = $processor->serialize_token();
if ( 0 !== substr_compare( $processor->get_html(), $normalized, $mark->start, $mark->length ) ) {
- $this->edits[] = array(
- 'start' => $mark->start,
- 'length' => $mark->length,
- 'replacement' => $normalized,
+ $this->edits[] = new WP_HTML_Text_Replacement(
+ $mark->start,
+ $mark->length,
+ $normalized,
);
}
break;
@@ -242,10 +242,10 @@ public function get_tag_attributes(): array {
);
// Only add edit if escaping actually changes the text.
if ( $escaped !== $original ) {
- $this->edits[] = array(
- 'start' => $last_offset,
- 'length' => $seg_length,
- 'replacement' => $escaped,
+ $this->edits[] = new WP_HTML_Text_Replacement(
+ $last_offset,
+ $seg_length,
+ $escaped,
);
}
}
@@ -280,10 +280,10 @@ public function get_tag_attributes(): array {
);
// Only add edit if escaping actually changes the text.
if ( $escaped !== $original ) {
- $this->edits[] = array(
- 'start' => $last_offset,
- 'length' => $seg_length,
- 'replacement' => $escaped,
+ $this->edits[] = new WP_HTML_Text_Replacement(
+ $last_offset,
+ $seg_length,
+ $escaped,
);
}
}
@@ -293,7 +293,7 @@ public function get_tag_attributes(): array {
}
}
- private function __construct( string $template_string, array $replacements ) {
+ private function __construct( string $template_string, ?array $replacements ) {
$this->template_string = $template_string;
$this->replacements = $replacements;
}
@@ -306,8 +306,8 @@ private function __construct( string $template_string, array $replacements ) {
* @param string $template The template string with placeholders.
* @return static The template instance.
*/
- public static function from( string $template ): static {
- return new static( $template, array() );
+ public static function from( string $template, ?array $replacements = null ): static {
+ return new static( $template, $replacements );
}
/**
@@ -363,7 +363,7 @@ public function bind( array $replacements ): static {
// Check for templates in attribute context.
foreach ( $this->edits as $edit ) {
- if ( ! isset( $edit['placeholder'] ) || 'attribute' !== $edit['context'] ) {
+ if ( $edit instanceof WP_HTML_Text_Replacement || 'attribute' !== $edit['context'] ) {
continue;
}
@@ -429,7 +429,10 @@ public function render(): string|false {
// Process edits in reverse order (end to start) to preserve positions.
foreach ( array_reverse( $this->edits ) as $edit ) {
- if ( isset( $edit['placeholder'] ) ) {
+ if ( $edit instanceof WP_HTML_Text_Replacement ) {
+ // Pre-computed replacement: apply directly.
+ $html = substr_replace( $html, $edit->text, $edit->start, $edit->length );
+ } else {
// Placeholder: look up replacement value.
$placeholder = $edit['placeholder'];
@@ -461,9 +464,6 @@ public function render(): string|false {
} else {
return false;
}
- } else {
- // Pre-computed replacement: apply directly.
- $html = substr_replace( $html, $edit['replacement'], $edit['start'], $edit['length'] );
}
}
From 14d5df99b8e9a77ea0edb396e3b71c3529bac16c Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Mon, 9 Feb 2026 14:02:19 +0100
Subject: [PATCH 53/66] Rely on HTML API text replacement
---
.../html-api/class-wp-html-template.php | 44 ++++++++++---------
1 file changed, 24 insertions(+), 20 deletions(-)
diff --git a/src/wp-includes/html-api/class-wp-html-template.php b/src/wp-includes/html-api/class-wp-html-template.php
index fdd2824236b94..c42c93ad7f4e1 100644
--- a/src/wp-includes/html-api/class-wp-html-template.php
+++ b/src/wp-includes/html-api/class-wp-html-template.php
@@ -413,6 +413,7 @@ public function render(): string|false {
$this->compile();
if ( empty( $this->replacements ) ) {
+ // @todo check for missing names.
return WP_HTML_Processor::normalize( $this->template_string ) ?? $this->template_string;
}
@@ -424,31 +425,24 @@ public function render(): string|false {
'"' => '"',
);
- $html = $this->template_string;
- $used_keys = array();
+ $processor = ( new class( $this->template_string ) extends WP_HTML_Tag_Processor {
+ public function push_update( WP_HTML_Text_Replacement $update ) {
+ $this->lexical_updates[] = $update;
+ }
+ } );
- // Process edits in reverse order (end to start) to preserve positions.
- foreach ( array_reverse( $this->edits ) as $edit ) {
+ $used_keys = array();
+ foreach ( $this->edits as $edit ) {
if ( $edit instanceof WP_HTML_Text_Replacement ) {
- // Pre-computed replacement: apply directly.
- $html = substr_replace( $html, $edit->text, $edit->start, $edit->length );
+ $processor->push_update( $edit );
} else {
// Placeholder: look up replacement value.
$placeholder = $edit['placeholder'];
-
- if ( array_key_exists( $placeholder, $this->replacements ) ) {
- $key = $placeholder;
- } elseif ( ctype_digit( $placeholder ) && array_key_exists( (int) $placeholder, $this->replacements ) ) {
- $key = (int) $placeholder;
- } else {
- return false;
- }
-
- $used_keys[ $key ] = true;
- $value = $this->replacements[ $key ];
+ $value = $this->replacements[ $placeholder ] ?? null;
if ( $value instanceof self ) {
if ( 'attribute' === $edit['context'] ) {
+ // @todo doing it wrong.
return false;
}
@@ -457,13 +451,19 @@ public function render(): string|false {
return false;
}
- $html = substr_replace( $html, $rendered, $edit['start'], $edit['length'] );
+ $processor->push_update(
+ new WP_HTML_Text_Replacement( $edit['start'], $edit['length'], $rendered ),
+ );
} elseif ( is_string( $value ) ) {
$escaped = strtr( $value, $escape_map );
- $html = substr_replace( $html, $escaped, $edit['start'], $edit['length'] );
+ $processor->push_update(
+ new WP_HTML_Text_Replacement( $edit['start'], $edit['length'], $escaped ),
+ );
} else {
+ // @todo doing it wrong.
return false;
}
+ $used_keys[ $placeholder ] = true;
}
}
@@ -472,6 +472,10 @@ public function render(): string|false {
return false;
}
- return WP_HTML_Processor::normalize( $html ) ?? $html;
+ /*
+ * @todo ideally, just call `$processor->serialize()`.
+ * @todo doing it wrong?
+ */
+ return WP_HTML_Processor::normalize( $processor->get_updated_html() ) ?? false;
}
}
From fa9d84e9b888f0dbfc5a10d0d254b4f478dd638d Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Mon, 9 Feb 2026 15:42:32 +0100
Subject: [PATCH 54/66] Tests: Refactor static text escaping test to use data
provider
Converts test_escapes_static_text_around_placeholder_in_attribute from
inline multiple assertions to a @dataProvider pattern for better test
isolation and clearer failure messages.
---
.../phpunit/tests/html-api/wpHtmlTemplate.php | 69 +++++++++++--------
1 file changed, 39 insertions(+), 30 deletions(-)
diff --git a/tests/phpunit/tests/html-api/wpHtmlTemplate.php b/tests/phpunit/tests/html-api/wpHtmlTemplate.php
index fa590562916b9..ce360562a66c7 100644
--- a/tests/phpunit/tests/html-api/wpHtmlTemplate.php
+++ b/tests/phpunit/tests/html-api/wpHtmlTemplate.php
@@ -902,40 +902,49 @@ public function test_extracts_attribute_placeholders() {
*
* @ticket 60229
*
+ * @dataProvider data_escapes_static_text_around_placeholder_in_attribute
+ *
* @covers ::from
* @covers ::bind
* @covers ::render
*/
- public function test_escapes_static_text_around_placeholder_in_attribute() {
- // Leading static text (prefix before placeholder)
- $result = T::from( 'Link' )
- ->bind( array( 'slug' => 'hello' ) )
- ->render();
- $this->assertEqualHTML( 'Link', $result );
-
- // Trailing static text (suffix after placeholder)
- $result = T::from( 'Link' )
- ->bind( array( 'slug' => 'hello' ) )
- ->render();
- $this->assertEqualHTML( 'Link', $result );
-
- // Ampersand in trailing static text must be escaped
- $result = T::from( 'Link' )
- ->bind( array( 'base' => '/search?q=test' ) )
- ->render();
- $this->assertEqualHTML( 'Link', $result );
-
- // Ampersand entity in leading static text must not be double-escaped
- $result = T::from( 'Link' )
- ->bind( array( 'val' => '2' ) )
- ->render();
- $this->assertEqualHTML( 'Link', $result );
-
- // Character reference in trailing static text is preserved (not double-escaped)
- $result = T::from( '' )
- ->bind( array( 'placeholder' => '' ) )
- ->render();
- $this->assertEqualHTML( '', $result );
+ public function test_escapes_static_text_around_placeholder_in_attribute( string $template_string, array $replacements, string $expected ) {
+ $result = T::from( $template_string )->bind( $replacements )->render();
+ $this->assertEqualHTML( $expected, $result );
+ }
+
+ public static function data_escapes_static_text_around_placeholder_in_attribute() {
+ return array(
+ 'leading static text (prefix before placeholder)' => array(
+ 'Link',
+ array( 'slug' => 'hello' ),
+ 'Link',
+ ),
+
+ 'trailing static text (suffix after placeholder)' => array(
+ 'Link',
+ array( 'slug' => 'hello' ),
+ 'Link',
+ ),
+
+ 'ampersand in trailing static text must be escaped' => array(
+ 'Link',
+ array( 'base' => '/search?q=test' ),
+ 'Link',
+ ),
+
+ 'ampersand entity in leading static text not double-escaped' => array(
+ 'Link',
+ array( 'val' => '2' ),
+ 'Link',
+ ),
+
+ 'character reference in trailing static text preserved' => array(
+ '',
+ array( 'placeholder' => '' ),
+ '',
+ ),
+ );
}
public function test_context_promotion_text_to_attribute() {
From 26a45328f6d9fefbda70c978b16676bf7a218a17 Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Mon, 9 Feb 2026 16:18:56 +0100
Subject: [PATCH 55/66] Remove dangling docblock
---
tests/phpunit/tests/html-api/wpHtmlTemplate.php | 10 ----------
1 file changed, 10 deletions(-)
diff --git a/tests/phpunit/tests/html-api/wpHtmlTemplate.php b/tests/phpunit/tests/html-api/wpHtmlTemplate.php
index ce360562a66c7..8419ce8e5ca1a 100644
--- a/tests/phpunit/tests/html-api/wpHtmlTemplate.php
+++ b/tests/phpunit/tests/html-api/wpHtmlTemplate.php
@@ -887,16 +887,6 @@ public function test_extracts_attribute_placeholders() {
$this->assertSame( 'attribute', $placeholders['c']['context'] );
}
- /**
- * Verifies context promotion from text to attribute.
- *
- * When a placeholder appears in both text and attribute contexts,
- * the attribute context takes precedence (more restrictive escaping).
- *
- * @ticket 60229
- *
- * @covers ::get_placeholders
- */
/**
* Verifies that static text around placeholders in attributes is escaped.
*
From 6ec99ea02d98bda412ff7bb02577e03d956baf0f Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Mon, 9 Feb 2026 16:20:53 +0100
Subject: [PATCH 56/66] Remove is_compiled (derive)
---
.../html-api/class-wp-html-template.php | 16 +++-------------
1 file changed, 3 insertions(+), 13 deletions(-)
diff --git a/src/wp-includes/html-api/class-wp-html-template.php b/src/wp-includes/html-api/class-wp-html-template.php
index c42c93ad7f4e1..47bd3b44f5ce6 100644
--- a/src/wp-includes/html-api/class-wp-html-template.php
+++ b/src/wp-includes/html-api/class-wp-html-template.php
@@ -26,14 +26,6 @@ class WP_HTML_Template {
*/
private ?array $replacements = null;
- /**
- * Whether the template has been compiled.
- *
- * @since 7.0.0
- * @var bool
- */
- private bool $is_compiled = false;
-
/**
* Unified edit operations list.
*
@@ -46,9 +38,9 @@ class WP_HTML_Template {
* ['start' => int, 'length' => int, 'placeholder' => string, 'context' => 'text'|'attribute']
*
* @since 7.0.0
- * @var (array{'start': int, 'length': int, 'placeholder': string, 'context': 'text'|'attribute'}|WP_HTML_Text_Replacement)[]
+ * @var null|array
*/
- private array $edits = array();
+ private ?array $edits = null;
/**
* Placeholder names for O(1) validation.
@@ -110,11 +102,10 @@ public function get_placeholders(): array {
* @since 7.0.0
*/
private function compile(): void {
- if ( $this->is_compiled ) {
+ if ( null !== $this->edits ) {
return;
}
- $this->is_compiled = true;
$this->edits = array();
$this->placeholder_names = array();
@@ -385,7 +376,6 @@ public function bind( array $replacements ): static {
}
$new = new static( $this->template_string, $replacements );
- $new->is_compiled = $this->is_compiled;
$new->edits = $this->edits;
$new->placeholder_names = $this->placeholder_names;
return $new;
From 73ff19e035bce19a88e92675e289a7f7af08a319 Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Mon, 9 Feb 2026 17:02:15 +0100
Subject: [PATCH 57/66] Add replacement warning test
---
.../phpunit/tests/html-api/wpHtmlTemplate.php | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/tests/phpunit/tests/html-api/wpHtmlTemplate.php b/tests/phpunit/tests/html-api/wpHtmlTemplate.php
index 8419ce8e5ca1a..802f3ad0a70ca 100644
--- a/tests/phpunit/tests/html-api/wpHtmlTemplate.php
+++ b/tests/phpunit/tests/html-api/wpHtmlTemplate.php
@@ -947,4 +947,22 @@ public function test_context_promotion_text_to_attribute() {
$this->assertSame( 'attribute', $placeholders['url']['context'] );
$this->assertCount( 2, $placeholders['url']['offsets'] );
}
+
+ /**
+ * @ticket 60229
+ */
+ public function test_warns_on_unrecognized_replacements() {
+ $this->setExpectedIncorrectUsage( 'WP_HTML_Template::bind' );
+ $template = T::from( '' );
+ $template->bind( array( 'extra' => 'oops' ) );
+ }
+
+ /**
+ * @ticket 60229
+ */
+ public function test_warns_on_omit_replacement() {
+ $this->setExpectedIncorrectUsage( 'WP_HTML_Template::bind' );
+ $template = T::from( '% omitted >' );
+ $template->bind( array() );
+ }
}
From 51640e630d46b45ca179af58a1501ee031c53ffe Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Mon, 9 Feb 2026 17:03:31 +0100
Subject: [PATCH 58/66] remove get_placeholders
---
.../html-api/class-wp-html-template.php | 42 ----------
.../phpunit/tests/html-api/wpHtmlTemplate.php | 84 -------------------
2 files changed, 126 deletions(-)
diff --git a/src/wp-includes/html-api/class-wp-html-template.php b/src/wp-includes/html-api/class-wp-html-template.php
index 47bd3b44f5ce6..e1f9503c215ff 100644
--- a/src/wp-includes/html-api/class-wp-html-template.php
+++ b/src/wp-includes/html-api/class-wp-html-template.php
@@ -50,48 +50,6 @@ class WP_HTML_Template {
*/
private array $placeholder_names = array();
- /**
- * Returns the compiled placeholder metadata.
- *
- * Derives the grouped structure from $edits on-demand.
- * If a placeholder appears in both text and attribute contexts,
- * the attribute context takes precedence (more restrictive).
- *
- * @since 7.0.0
- *
- * @return array Associative array of placeholder_name => metadata.
- */
- public function get_placeholders(): array {
- $this->compile();
-
- $result = array();
-
- foreach ( $this->edits as $edit ) {
- if ( ! isset( $edit['placeholder'] ) ) {
- continue;
- }
-
- $placeholder = $edit['placeholder'];
- $context = $edit['context'];
-
- if ( ! isset( $result[ $placeholder ] ) ) {
- $result[ $placeholder ] = array(
- 'offsets' => array(),
- 'context' => $context,
- );
- } else {
- // Promote text context to attribute context.
- if ( 'attribute' === $context ) {
- $result[ $placeholder ]['context'] = 'attribute';
- }
- }
-
- $result[ $placeholder ]['offsets'][] = array( $edit['start'], $edit['length'] );
- }
-
- return $result;
- }
-
/**
* Compiles the template to extract placeholder metadata.
*
diff --git a/tests/phpunit/tests/html-api/wpHtmlTemplate.php b/tests/phpunit/tests/html-api/wpHtmlTemplate.php
index 802f3ad0a70ca..29dfa1c78fd37 100644
--- a/tests/phpunit/tests/html-api/wpHtmlTemplate.php
+++ b/tests/phpunit/tests/html-api/wpHtmlTemplate.php
@@ -814,79 +814,6 @@ public function test_bind_warns_on_template_in_attribute_context() {
$template->bind( array( 'html' => T::from( 'nested' ) ) );
}
- /**
- * Verifies that get_placeholders returns placeholder metadata.
- *
- * @ticket 60229
- *
- * @covers ::get_placeholders
- */
- public function test_get_placeholders_returns_metadata() {
- $template = T::from( '
' )->bind( array( 'val' => true ) )->render();
+ $this->assertFalse( $result );
+ }
}
From a8ca49d896c1ec6027540455338071741f4a7e19 Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Tue, 10 Feb 2026 17:40:07 +0100
Subject: [PATCH 61/66] Add status assessment
---
STATUS.md | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 118 insertions(+)
create mode 100644 STATUS.md
diff --git a/STATUS.md b/STATUS.md
new file mode 100644
index 0000000000000..5e4ccae50ec72
--- /dev/null
+++ b/STATUS.md
@@ -0,0 +1,118 @@
+## Critical Analysis: WP_HTML_Template vs. Ticket #60229 Requirements
+
+I've examined ticket #60229, the current branch implementation, and the test suite. Here's my analysis:
+
+---
+
+### What's Working Well
+
+**1. Core Safety Goal Achieved**
+The implementation delivers on the primary promise: context-aware auto-escaping. The code correctly escapes `&`, `<`, `>`, `"`, `'` in both text and attribute contexts. Tests confirm placeholders can't be injected via replacement values (non-recursive replacement).
+
+**2. Funky Comment Syntax**
+The `%placeholder>` syntax is a smart choice—it's valid HTML (parsed as a funky comment), can't be nested by construction, and is visually distinctive. The parsing handles whitespace around names correctly.
+
+**3. Nested Templates**
+Template composition (`WP_HTML_Template` as replacement value) works for text context with proper escaping propagation. The rejection of templates in attribute context is the right safety call.
+
+**4. Compile-once Design**
+Lazy compilation with cached edits (`$edits` array) is efficient for template reuse.
+
+---
+
+### What's Missing from the Ticket Requirements
+
+**1. No URL Escaping (Ticket TODO)**
+The ticket explicitly says "does not escape URLs differently than other attributes." The XSS test shows `javascript:alert("xss")` only escapes quotes—no `esc_url()` equivalent. This is a security gap for `href`/`src` attributes.
+
+**2. Boolean Attributes Not Implemented**
+Ticket promises: "supply true to create a boolean attribute or false/null remove an attribute." The current implementation only handles string and Template replacement values. No boolean support visible.
+
+**3. No Attribute Spread**
+Ticket comment 9 discusses "spread" attributes for making tags placeholders. Not implemented.
+
+**4. Missing Output Format Methods**
+Ticket TODO lists `->final_output_to_browser()`, `->final_output_to_plaintext()`, `->final_output_to_markdown()`, etc. None exist.
+
+**5. Embed Replacement in Tag Processor (Ticket TODO)**
+The ticket wants replacement embedded in the Tag Processor. Current implementation uses a separate class with its own parsing pass.
+
+---
+
+### Overlooked Issues in the Ticket
+
+**1. RAWTEXT/RCDATA Element Handling Is Half-Baked**
+Tests show placeholders inside `