',
+ 'TH' => '',
+ 'CAPTION' => '',
+ 'COLGROUP' => '',
+ );
+
+ $wrapper_html = $table_contexts[ $context_element ] ?? null;
+
+ if ( null === $wrapper_html ) {
+ // Non-table context: body context is correct.
+ return static::create_fragment( $html );
+ }
+
+ // Parse wrapper to position at the target context element.
+ $wrapper = static::create_fragment( $wrapper_html . 'x ' );
+ if ( null === $wrapper ) {
+ return null;
+ }
+
+ // Walk to the target context element.
+ while ( $wrapper->next_token() ) {
+ if ( '#tag' === $wrapper->get_token_type()
+ && ! $wrapper->is_tag_closer()
+ && $context_element === $wrapper->get_tag()
+ ) {
+ $create_fn = Closure::bind(
+ function () use ( $html ) {
+ return $this->create_fragment_at_current_node( $html );
+ },
+ $wrapper,
+ WP_HTML_Processor::class
+ );
+ return $create_fn();
+ }
+ }
+ return null;
+ }
+ } )::create_fragment( $html );
+ }
+
+ /**
+ * Processes a template with a given processor, building the output string.
+ *
+ * Walks all tokens in the processor, serializing each one. Placeholders in
+ * text context (funky comments) and attribute values are detected and replaced.
+ *
+ * @since 7.1.0
+ *
+ * @param self $template The template with replacements.
+ * @param WP_HTML_Processor $processor The processor to walk.
+ * @return string|false The rendered HTML, or false on error.
+ */
+ private static function process( self $template, WP_HTML_Processor $processor ) {
+ $output = '';
+ $used_keys = array();
+
+ while ( $processor->next_token() ) {
+ $token_type = $processor->get_token_type();
+
+ switch ( $token_type ) {
+ case '#funky-comment':
+ $result = static::process_placeholder( $processor, $template, $used_keys );
+ if ( false === $result ) {
+ return false;
+ }
+ if ( null !== $result ) {
+ $output .= $result;
+ } else {
+ // Not a placeholder — serialize normally.
+ $output .= $processor->serialize_token();
+ }
+ break;
+
+ case '#tag':
+ if ( $processor->is_tag_closer() ) {
+ $output .= $processor->serialize_token();
+ break;
+ }
+ $result = static::process_tag( $processor, $template, $used_keys );
+ if ( false === $result ) {
+ return false;
+ }
+ $output .= $result;
+ break;
+
+ default:
+ $output .= $processor->serialize_token();
+ break;
+ }
+ }
+
+ // Validate: all replacement keys were used.
+ if ( count( $used_keys ) !== count( $template->replacements ) ) {
+ foreach ( $template->replacements as $key => $_ ) {
+ if ( ! isset( $used_keys[ $key ] ) ) {
+ _doing_it_wrong(
+ __CLASS__ . '::render',
+ sprintf(
+ /* translators: %s: The unused replacement key name. */
+ __( 'Unused replacement key: %s.' ),
+ $key
+ ),
+ '7.1.0'
+ );
+ }
+ }
+ return false;
+ }
+
+ return $output;
+ }
+
+ /**
+ * Attempts to process a funky comment as a placeholder.
+ *
+ * @since 7.1.0
+ *
+ * @param WP_HTML_Processor $processor The processor positioned at a funky comment.
+ * @param self $template The template with replacements.
+ * @param array &$used_keys Tracks which replacement keys have been used.
+ * @return string|false|null The replacement string, false on error, or null if not a placeholder.
+ */
+ private static function process_placeholder(
+ WP_HTML_Processor $processor,
+ self $template,
+ array &$used_keys
+ ) {
+ $text = $processor->get_modifiable_text();
+
+ // Must start with `%`.
+ if ( '' === $text || '%' !== $text[0] ) {
+ return null;
+ }
+
+ $placeholder = trim( substr( $text, 1 ), " \t\n\r\f" );
+
+ // Valid placeholders match `/[a-z][a-z0-9_-]*/i`.
+ if (
+ '' === $placeholder ||
+ ! ctype_alpha( $placeholder[0] ) ||
+ strlen( $placeholder ) !== strspn( $placeholder, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-' )
+ ) {
+ return null;
+ }
+
+ if ( ! array_key_exists( $placeholder, $template->replacements ) ) {
+ _doing_it_wrong(
+ __CLASS__ . '::render',
+ sprintf(
+ /* translators: %s: The placeholder name. */
+ __( 'Missing replacement for placeholder: %s.' ),
+ $placeholder
+ ),
+ '7.1.0'
+ );
+ return false;
+ }
+
+ $value = $template->replacements[ $placeholder ];
+ $used_keys[ $placeholder ] = true;
+
+ if ( is_string( $value ) ) {
+ return strtr( $value, self::ESCAPE_MAP );
+ }
+
+ if ( $value instanceof self ) {
+ // Get the parent context element from breadcrumbs.
+ // The last breadcrumb is the current token (the funky comment);
+ // the parent element is the second-to-last entry.
+ $breadcrumbs = $processor->get_breadcrumbs();
+ $context_element = $breadcrumbs[ count( $breadcrumbs ) - 2 ] ?? 'BODY';
+
+ // Create a child processor in the correct context.
+ $child_processor = $processor->create_fragment_for_context(
+ $value->template_string,
+ $context_element
+ );
+
+ if ( null === $child_processor ) {
+ return false;
+ }
+
+ if ( empty( $value->replacements ) ) {
+ return static::serialize_all( $child_processor );
+ }
+
+ return static::process( $value, $child_processor );
+ }
+
+ // Boolean and null are invalid in text context.
+ _doing_it_wrong(
+ __CLASS__ . '::render',
+ sprintf(
+ /* translators: %s: The placeholder name. */
+ __( 'Invalid replacement type for text placeholder: %s.' ),
+ $placeholder
+ ),
+ '7.1.0'
+ );
+ return false;
+ }
+
+ /**
+ * Processes an opening tag, handling attribute placeholders.
+ *
+ * If no attribute contains a placeholder, delegates to serialize_token().
+ * Otherwise, builds the tag manually with placeholder replacements.
+ *
+ * @since 7.1.0
+ *
+ * @param WP_HTML_Processor $processor The processor positioned at an opening tag.
+ * @param self $template The template with replacements.
+ * @param array &$used_keys Tracks which replacement keys have been used.
+ * @return string|false The serialized tag HTML, or false on error.
+ */
+ private static function process_tag(
+ WP_HTML_Processor $processor,
+ self $template,
+ array &$used_keys
+ ): string|false {
+ $attributes = $processor->get_tag_attributes();
+ $raw_html = $processor->get_html();
+
+ // Quick check: does any attribute value contain a placeholder pattern?
+ $has_placeholder = false;
+ foreach ( $attributes as $attribute ) {
+ if ( $attribute->is_true ) {
+ continue;
+ }
+ if ( $attribute->value_length >= 5 ) {
+ $raw_value = substr( $raw_html, $attribute->value_starts_at, $attribute->value_length );
+ if ( str_contains( $raw_value, '%' ) ) {
+ $has_placeholder = true;
+ break;
+ }
+ }
+ }
+
+ if ( ! $has_placeholder ) {
+ return $processor->serialize_token();
+ }
+
+ // Build the tag manually to handle attribute placeholders.
+ $tag_name = str_replace( "\x00", "\u{FFFD}", $processor->get_tag() );
+ $in_html = 'html' === $processor->get_namespace();
+ $qualified_name = $in_html ? strtolower( $tag_name ) : $processor->get_qualified_tag_name();
+
+ $html = "<{$qualified_name}";
+
+ // Track attributes to skip (removed by false/null).
+ $skip_attributes = array();
+
+ foreach ( $attributes as $attribute ) {
+ if ( isset( $skip_attributes[ $attribute->name ] ) ) {
+ continue;
+ }
+
+ if ( $attribute->is_true ) {
+ $html .= " {$attribute->name}";
+ continue;
+ }
+
+ // Check for placeholders in the raw attribute value.
+ $raw_value = substr( $raw_html, $attribute->value_starts_at, $attribute->value_length );
+ if ( ! str_contains( $raw_value, '%' ) ) {
+ // No placeholder — use standard serialization.
+ $decoded = $processor->get_attribute( $attribute->name );
+ if ( is_string( $decoded ) ) {
+ $html .= ' ' . $attribute->name . '="' . htmlspecialchars( $decoded, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8' ) . '"';
+ } else {
+ $html .= " {$attribute->name}";
+ }
+ continue;
+ }
+
+ // Scan for placeholders in the attribute value.
+ $result = static::process_attribute_value(
+ $raw_value,
+ $attribute,
+ $template,
+ $used_keys,
+ $skip_attributes
+ );
+
+ if ( false === $result ) {
+ return false;
+ }
+
+ if ( null === $result ) {
+ // Attribute was removed (false/null replacement).
+ continue;
+ }
+
+ $html .= $result;
+ }
+
+ if ( ! $in_html && $processor->has_self_closing_flag() ) {
+ $html .= ' /';
+ }
+
+ $html .= '>';
+
+ // Handle PRE/TEXTAREA/LISTING leading newline.
+ if ( 'TEXTAREA' === $tag_name || 'PRE' === $tag_name || 'LISTING' === $tag_name ) {
+ $html .= "\n";
+ }
+
+ // Handle self-contained elements (their content + closing tag is part of this token).
+ if ( $in_html && in_array( $tag_name, array( 'IFRAME', 'NOEMBED', 'NOFRAMES', 'SCRIPT', 'STYLE', 'TEXTAREA', 'TITLE', 'XMP' ), true ) ) {
+ $text = $processor->get_modifiable_text();
+
+ switch ( $tag_name ) {
+ case 'IFRAME':
+ case 'NOEMBED':
+ case 'NOFRAMES':
+ $text = '';
+ break;
+
+ case 'SCRIPT':
+ case 'STYLE':
+ break;
+
+ default:
+ $text = htmlspecialchars( $text, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8' );
+ }
+
+ $html .= "{$text}{$qualified_name}>";
+ }
+
+ return $html;
+ }
+
+ /**
+ * Processes an attribute value that contains placeholder(s).
+ *
+ * @since 7.1.0
+ *
+ * @param string $raw_value The raw attribute value from the HTML.
+ * @param object $attribute The attribute token object.
+ * @param self $template The template with replacements.
+ * @param array &$used_keys Tracks which replacement keys have been used.
+ * @param array &$skip_attributes Attributes to skip in serialization.
+ * @return string|false|null The serialized attribute, false on error, or null if removed.
+ */
+ private static function process_attribute_value(
+ string $raw_value,
+ $attribute,
+ self $template,
+ array &$used_keys,
+ array &$skip_attributes
+ ): string|false|null {
+ $offset = 0;
+ $end = strlen( $raw_value );
+ $value_html = '';
+
+ // Is this a single placeholder that covers the entire attribute value?
+ $is_whole_attribute = (bool) preg_match(
+ '#^%[ \\t\\r\\f\\n]*[a-z][a-z0-9_-]*[ \\t\\r\\f\\n]*>$#i',
+ $raw_value
+ );
+
+ while (
+ 1 === preg_match(
+ '#%[ \\t\\r\\f\\n]*([a-z][a-z0-9_-]*)[ \\t\\r\\f\\n]*>#i',
+ $raw_value,
+ $matches,
+ PREG_OFFSET_CAPTURE,
+ $offset
+ )
+ && $matches[0][1] < $end
+ ) {
+ $placeholder = $matches[1][0];
+ $match_start = $matches[0][1];
+ $match_length = strlen( $matches[0][0] );
+
+ if ( ! array_key_exists( $placeholder, $template->replacements ) ) {
+ _doing_it_wrong(
+ __CLASS__ . '::render',
+ sprintf(
+ /* translators: %s: The placeholder name. */
+ __( 'Missing replacement for placeholder: %s.' ),
+ $placeholder
+ ),
+ '7.1.0'
+ );
+ return false;
+ }
+
+ $value = $template->replacements[ $placeholder ];
+ $used_keys[ $placeholder ] = true;
+
+ // Template in attribute context is invalid.
+ if ( $value instanceof self ) {
+ _doing_it_wrong(
+ __CLASS__ . '::render',
+ sprintf(
+ /* translators: %s: The placeholder name. */
+ __( 'Template cannot be used in attribute context: %s.' ),
+ $placeholder
+ ),
+ '7.1.0'
+ );
+ return false;
+ }
+
+ // Boolean handling — only valid for whole-attribute placeholders.
+ if ( true === $value ) {
+ if ( ! $is_whole_attribute ) {
+ return false;
+ }
+ // Convert to boolean attribute (just the name, no value).
+ return " {$attribute->name}";
+ }
+
+ if ( false === $value || null === $value ) {
+ if ( ! $is_whole_attribute ) {
+ return false;
+ }
+ // Remove the attribute entirely.
+ $skip_attributes[ $attribute->name ] = true;
+ return null;
+ }
+
+ if ( ! is_string( $value ) ) {
+ _doing_it_wrong(
+ __CLASS__ . '::render',
+ sprintf(
+ /* translators: %s: The placeholder name. */
+ __( 'Invalid replacement type for attribute placeholder: %s.' ),
+ $placeholder
+ ),
+ '7.1.0'
+ );
+ return false;
+ }
+
+ // Static text before placeholder.
+ if ( $match_start > $offset ) {
+ $segment = substr( $raw_value, $offset, $match_start - $offset );
+ $decoded = WP_HTML_Decoder::decode_attribute( $segment );
+ $value_html .= strtr( $decoded, self::ESCAPE_MAP );
+ }
+
+ // Escaped replacement value.
+ $value_html .= strtr( $value, self::ESCAPE_MAP );
+
+ $offset = $match_start + $match_length;
+ }
+
+ // Trailing static text after last placeholder.
+ if ( $offset < $end ) {
+ $segment = substr( $raw_value, $offset );
+ $decoded = WP_HTML_Decoder::decode_attribute( $segment );
+ $value_html .= strtr( $decoded, self::ESCAPE_MAP );
+ }
+
+ return ' ' . $attribute->name . '="' . $value_html . '"';
+ }
+
+ /**
+ * Serializes all tokens from a processor into a string.
+ *
+ * @since 7.1.0
+ *
+ * @param WP_HTML_Processor $processor The processor to serialize.
+ * @return string The serialized HTML.
+ */
+ private static function serialize_all( WP_HTML_Processor $processor ): string {
+ $html = '';
+ while ( $processor->next_token() ) {
+ $html .= $processor->serialize_token();
+ }
+ return $html;
+ }
+}
diff --git a/src/wp-settings.php b/src/wp-settings.php
index 023cdccd5ecc9..8c8ca44b032d9 100644
--- a/src/wp-settings.php
+++ b/src/wp-settings.php
@@ -276,6 +276,7 @@
require ABSPATH . WPINC . '/html-api/class-wp-html-stack-event.php';
require ABSPATH . WPINC . '/html-api/class-wp-html-processor-state.php';
require ABSPATH . WPINC . '/html-api/class-wp-html-processor.php';
+require ABSPATH . WPINC . '/html-api/class-wp-html-template.php';
require ABSPATH . WPINC . '/class-wp-block-processor.php';
require ABSPATH . WPINC . '/class-wp-http.php';
require ABSPATH . WPINC . '/class-wp-http-streams.php';
diff --git a/tests/phpunit/tests/html-api/wpHtmlTemplate.php b/tests/phpunit/tests/html-api/wpHtmlTemplate.php
new file mode 100644
index 0000000000000..b1d8d454c176e
--- /dev/null
+++ b/tests/phpunit/tests/html-api/wpHtmlTemplate.php
@@ -0,0 +1,1197 @@
+>s';
+ $replacements = array( 'tag-name' => 'i' );
+ $result = T::render( $template_string, $replacements );
+
+ $expected = 'a<i>s';
+ $this->assertEqualHTML( $expected, $result );
+ }
+
+ /**
+ * Verifies that only the first of duplicate attributes is replaced.
+ *
+ * Note: Duplicate attributes are stripped per HTML spec, so placeholders
+ * in duplicate attributes are ignored. Providing a replacement for such
+ * placeholders would be an unused key error.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ */
+ public function test_replaces_only_in_first_duplicate_attribute() {
+ $template_string = ' ';
+ $replacements = array(
+ 'replace' => 'O',
+ 'replace-2' => 'K',
+ );
+
+ $result = T::render( $template_string, $replacements );
+
+ $expected = ' ';
+ $this->assertEqualHTML( $expected, $result );
+ }
+
+ /**
+ * Verifies that attribute replacement is not recursive.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ */
+ public function test_attribute_replacement_is_not_recursive() {
+ $template_string = '%replace>
';
+ $replacements = array(
+ 'replace' => '%replace>',
+ );
+
+ $result = T::render( $template_string, $replacements );
+
+ $expected = '</%replace>
';
+ $this->assertEqualHTML( $expected, $result );
+ }
+
+ /**
+ * Verifies that placeholder names allow surrounding whitespace.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ */
+ public function test_placeholder_names_allow_surrounding_whitespace() {
+ $template_string = " ";
+ $replacements = array(
+ 'n' => 'the name',
+ 'c' => 'the "content" & whatever else',
+ );
+
+ $result = T::render( $template_string, $replacements );
+
+ $expected =
+ <<<'HTML'
+
+ HTML;
+ $this->assertEqualHTML( $expected, $result );
+ }
+
+ /**
+ * Verifies that ampersands are escaped to prevent character reference injection.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ */
+ public function test_escapes_ampersand_to_prevent_character_reference_injection() {
+ $template_string = ' ';
+ $replacements = array( 'placeholder' => 'not' );
+ $result = T::render( $template_string, $replacements );
+
+ $expected =
+ <<<'HTML'
+
+ HTML;
+ $this->assertEqualHTML( $expected, $result );
+ }
+
+ /**
+ * Verifies that nested templates are rejected in attribute values.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ * @covers ::template
+ *
+ * @expectedIncorrectUsage WP_HTML_Template::render
+ */
+ public function test_rejects_nested_template_in_attribute_value() {
+ $template_string = ' ';
+ $replacements = array(
+ 'html' => T::template( 'This is not allowed! ' ),
+ );
+ $this->assertFalse( T::render( $template_string, $replacements ) );
+ }
+
+ /**
+ * @dataProvider data_template
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ * @covers ::template
+ */
+ public function test_template( string $template_string, array $replacements, string $expected ) {
+ $result = T::render( $template_string, $replacements );
+ $this->assertEqualHTML( $expected, $result );
+ }
+
+ public static function data_template() {
+ return array(
+ 'basic template (no placeholders)' => array(
+ 'Hi!
',
+ array(),
+ 'Hi!
',
+ ),
+
+ 'basic text replacement' => array(
+ 'Hello, %name>!
',
+ array( 'name' => 'World' ),
+ 'Hello, World!
',
+ ),
+
+ 'escapes special characters in text' => array(
+ 'Hello, %placeholder>!
',
+ array( 'placeholder' => 'Alice & Bob' ),
+ 'Hello, Alice & Bob!
',
+ ),
+
+ 'escapes angle brackets in text' => array(
+ 'Hello, %name>!
',
+ array( 'name' => '' ),
+ 'Hello, <little-bobby-tags>!
',
+ ),
+
+ 'repeated placeholders' => array(
+ '%a>, % a >, %name>, & %name>!
',
+ array(
+ 'a' => 'Alice',
+ 'name' => 'Bob',
+ ),
+ 'Alice, Alice, Bob, & Bob!
',
+ ),
+
+ 'nested template replacement' => array(
+ 'Hello, %html>',
+ array( 'html' => WP_HTML_Template::template( 'Alice & Bob ' ) ),
+ '
Hello, Alice & Bob
',
+ ),
+
+ 'replaces attribute values' => array(
+ ' ',
+ array(
+ 'n' => 'the name',
+ 'c' => 'the content',
+ ),
+ ' ',
+ ),
+
+ 'escapes attribute values' => array(
+ ' ',
+ array(
+ 'c' => 'the "content" & whatever else',
+ ),
+ ' ',
+ ),
+ );
+ }
+
+ /**
+ * Test real-world patterns from WordPress core.
+ *
+ * @dataProvider data_real_world_examples
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ * @covers ::template
+ */
+ public function test_real_world_examples( string $template_string, array $replacements, string $expected ) {
+ $result = WP_HTML_Template::render( $template_string, $replacements );
+ $this->assertEqualHTML( $expected, $result );
+ }
+
+ /**
+ * Data provider with real-world patterns from WordPress core.
+ *
+ * Each test case is based on actual code patterns found in WordPress core
+ * that could benefit from the WP_HTML_Template API.
+ *
+ * @return array[]
+ */
+ public static function data_real_world_examples() {
+ /*
+ * Group 1: Simple sprintf patterns with manual escaping.
+ *
+ * These patterns currently require developers to manually choose
+ * the correct escape function (esc_url, esc_attr, esc_html).
+ */
+
+ // src/wp-includes/formatting.php:3476 - Smiley image
+ yield 'formatting.php:3476 - smiley image tag' => array(
+ <<<'HTML'
+
+ HTML,
+ array(
+ 'src' => 'https://example.com/smilies/:).png',
+ 'alt' => ':)',
+ ),
+ <<<'HTML'
+
+ HTML,
+ );
+
+ // src/wp-includes/blocks/post-title.php:41 - Post title link
+ yield 'blocks/post-title.php:41 - post title link' => array(
+ '%title> ',
+ array(
+ 'url' => 'https://example.com/hello-world/',
+ 'target' => '_blank',
+ 'title' => 'Hello World',
+ ),
+ 'Hello World ',
+ );
+
+ // Same pattern with escaping needed
+ yield 'blocks/post-title.php:41 - post title link with special chars' => array(
+ "\n%title>\n ",
+ array(
+ 'url' => 'https://example.com/hello-world/?foo=1&bar=2',
+ 'target' => '_blank',
+ 'title' => WP_HTML_Template::template(
+ '\'%italic> \' & "%bold>" ',
+ array(
+ 'italic' => 'This',
+ 'bold' => 'That',
+ )
+ ),
+ ),
+ <<<'HTML'
+
+ 'This ' & "That"
+
+ HTML,
+ );
+
+ /*
+ * Group 2: Translation patterns with embedded HTML.
+ *
+ * These patterns have HTML directly in translatable strings.
+ */
+
+ // src/wp-includes/functions.php:1620 - Error message (static, no placeholders)
+ yield 'functions.php:1620 - static error message' => array(
+ 'Error: This is not a valid feed template.',
+ array(),
+ 'Error: This is not a valid feed template.',
+ );
+
+ // src/wp-includes/functions.php:1844-1845 - Database repair link
+ yield 'functions.php:1844 - database repair link' => array(
+ <<<'HTML'
+ One or more database tables are unavailable. The database may need to be repaired .
+ HTML,
+ array(
+ 'url' => 'maint/repair.php?referrer=is_blog_installed',
+ ),
+ <<<'HTML'
+ One or more database tables are unavailable. The database may need to be repaired .
+ HTML,
+ );
+
+ // src/wp-admin/edit-form-advanced.php:185 - Scheduled post date
+ yield 'edit-form-advanced.php:185 - scheduled post date' => array(
+ 'Post scheduled for: %date> .',
+ array(
+ 'date' => 'March 15, 2025 at 10:30 am',
+ ),
+ 'Post scheduled for: March 15, 2025 at 10:30 am .',
+ );
+
+ // src/wp-includes/blocks/latest-posts.php:164-166 - Read more link with nested elements
+ yield 'blocks/latest-posts.php:164 - read more link with screen reader text' => array(
+ <<<'HTML'
+ … Read more: %title>
+ HTML,
+ array(
+ 'url' => 'https://example.com/my-post/',
+ 'title' => 'My Amazing Post',
+ ),
+ <<<'HTML'
+ … Read more: My Amazing Post
+ HTML,
+ );
+
+ // Same pattern with escaping needed
+ yield 'blocks/latest-posts.php:164 - read more with XSS attempt' => array(
+ <<<'HTML'
+ … Read more: %title>
+ HTML,
+ array(
+ 'url' => 'javascript:alert("xss")',
+ 'title' => '',
+ ),
+ <<<'HTML'
+ … Read more: <script>alert("xss")</script>
+ HTML,
+ );
+
+ // src/wp-includes/theme.php:978-979 - Theme error with name
+ yield 'theme.php:978 - theme error message' => array(
+ <<<'HTML'
+ Error: Current WordPress and PHP versions do not meet minimum requirements for %theme_name>.
+ HTML,
+ array(
+ 'theme_name' => 'Twenty Twenty-Five',
+ ),
+ <<<'HTML'
+ Error: Current WordPress and PHP versions do not meet minimum requirements for Twenty Twenty-Five.
+ HTML,
+ );
+
+ // src/wp-admin/includes/privacy-tools.php:404 - Code tag in error
+ yield 'privacy-tools.php:404 - code in error message' => array(
+ 'The %meta_key> post meta must be an array.',
+ array(
+ 'meta_key' => '_export_data_grouped',
+ ),
+ 'The _export_data_grouped post meta must be an array.',
+ );
+
+ /*
+ * Group 3: Edge cases.
+ */
+
+ // Placeholder reuse (same placeholder multiple times)
+ yield 'placeholder reuse' => array(
+ 'Name: ',
+ array(
+ 'id' => 'user_name',
+ ),
+ 'Name: ',
+ );
+
+ // Named placeholders
+ yield 'named placeholders' => array(
+ '%postTitle> by %authorName> ',
+ array(
+ 'postUrl' => 'https://example.com/post/',
+ 'postTitle' => 'Post Title',
+ 'authorUrl' => 'https://example.com/author/',
+ 'authorName' => 'Author Name',
+ ),
+ 'Post Title by Author Name ',
+ );
+
+ // Nested template (pre-escaped HTML)
+ yield 'nested template for complex structure' => array(
+ '%icon> %message>
',
+ array(
+ 'icon' => WP_HTML_Template::template( ' ' ),
+ 'message' => 'Something went wrong.',
+ ),
+ ' Something went wrong.
',
+ );
+
+ // Empty replacement
+ yield 'empty replacement value' => array(
+ 'Hello%suffix>
',
+ array(
+ 'suffix' => '',
+ ),
+ 'Hello
',
+ );
+
+ // HTML entities in template (should be preserved)
+ yield 'HTML entities in template' => array(
+ '“%quote>”
',
+ array(
+ 'quote' => 'Hello World',
+ ),
+ '“Hello World”
',
+ );
+
+ // Multiple attributes on same element
+ yield 'multiple attributes on element' => array(
+ ' ',
+ array(
+ 'type' => 'text',
+ 'name' => 'user_email',
+ 'value' => 'test@example.com',
+ 'placeholder' => 'Enter your email',
+ ),
+ ' ',
+ );
+
+ // Attribute value with quotes and special characters
+ yield 'attribute with quotes and ampersands' => array(
+ 'Link ',
+ array(
+ 'url' => 'https://example.com/?a=1&b=2',
+ 'title' => <<<'TEXT'
+ Click "here" for Tom & Jerry
+ TEXT,
+ ),
+ <<<'HTML'
+ Link
+ HTML,
+ );
+
+ // Self-closing void element
+ yield 'self-closing meta tag' => array(
+ ' ',
+ array(
+ 'name' => 'description',
+ 'content' => <<<'TEXT'
+ A page about "cats" & dogs
+ TEXT,
+ ),
+ <<<'HTML'
+
+ HTML,
+ );
+
+ // src/wp-includes/blocks/avatar.php:68 - Complex link with aria-label
+ yield 'blocks/avatar.php:68 - avatar link' => array(
+ <<<'HTML'
+ %inner>
+ HTML,
+ array(
+ 'url' => 'https://example.com/author/johndoe/',
+ 'target' => '_blank',
+ 'aria_label' => '(John Doe author archive, opens in a new tab)',
+ 'inner' => WP_HTML_Template::template( ' ' ),
+ ),
+ <<<'HTML'
+
+ HTML,
+ );
+ }
+
+ /**
+ * Verifies nested templates work correctly in a definition list.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ * @covers ::template
+ */
+ public function test_nested_templates_in_definition_list() {
+ $row_replacements = array();
+ for ( $i = 1; $i <= 3; $i++ ) {
+ $row_replacements[ "row-{$i}" ] = WP_HTML_Template::template(
+ "%term> \n%definition> ",
+ array(
+ 'term' => "Term \"{$i}\"",
+ 'definition' => WP_HTML_Template::template(
+ 'IYKYK : %i>',
+ array(
+ 'i' => (string) $i,
+ 'expansion' => '"If You Know You Know"',
+ )
+ ),
+ )
+ );
+ }
+
+ $result = WP_HTML_Template::render(
+ <<<'HTML'
+
+ %row-1>
+ %row-2>
+ %row-3>
+
+ HTML,
+ $row_replacements
+ );
+
+ $expected =
+ <<<'HTML'
+
+ Term "1"
+ IYKYK : 1
+ Term "2"
+ IYKYK : 2
+ Term "3"
+ IYKYK : 3
+
+ HTML;
+
+ $this->assertEqualHTML( $expected, $result );
+ }
+
+ /**
+ * Verifies that table row templates work when nested in table context.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ * @covers ::template
+ */
+ public function test_table_row_template_with_placeholders() {
+ $row = WP_HTML_Template::template(
+ '%cell1> %cell2> ',
+ array(
+ 'cell1' => 'Hello',
+ 'cell2' => 'World',
+ )
+ );
+
+ $result = WP_HTML_Template::render(
+ '',
+ array( 'row' => $row )
+ );
+
+ // Use assertStringContainsString to verify table elements aren't discarded.
+ // assertEqualHTML would pass incorrectly because both expected and actual
+ // get parsed in BODY context where / are discarded.
+ $this->assertStringContainsString( ' ', $result, 'Table row element should be preserved.' );
+ $this->assertStringContainsString( 'Hello ', $result );
+ $this->assertStringContainsString( 'World ', $result );
+ }
+
+ /**
+ * Verifies table templates work with thead and tbody.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ * @covers ::template
+ */
+ public function test_table_templates_with_thead_and_tbody() {
+ $header = WP_HTML_Template::template(
+ ' %col1> %col2> ',
+ array(
+ 'col1' => 'Name',
+ 'col2' => 'Value',
+ )
+ );
+
+ $row = WP_HTML_Template::template(
+ '%name> %value> ',
+ array(
+ 'name' => 'Alice',
+ 'value' => '42',
+ )
+ );
+
+ $result = WP_HTML_Template::render(
+ '',
+ array(
+ 'header' => $header,
+ 'row' => $row,
+ )
+ );
+
+ $this->assertStringContainsString( 'Name Value ', $result );
+ $this->assertStringContainsString( 'Alice 42 ', $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 ::render
+ */
+ public function test_atomic_element_attributes_are_replaced( string $template_string, array $replacements, string $expected ) {
+ $result = T::render( $template_string, $replacements );
+ $this->assertEqualHTML( $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.
+ *
+ * With strict validation, providing a replacement for a placeholder that won't be
+ * processed (inside SCRIPT/STYLE/TITLE/TEXTAREA) is an unused key error.
+ *
+ * @ticket 60229
+ *
+ * @dataProvider data_atomic_element_content_placeholders
+ *
+ * @todo Implement correct handling of atomic elements.
+ *
+ * @covers ::render
+ */
+ public function test_special_element_content_placeholder_behavior( string $template_string, string $expected ) {
+ $result = T::render( $template_string );
+ $this->assertEqualHTML( $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 preserved' => array(
+ '',
+ '',
+ ),
+
+ 'STYLE content placeholder preserved' => array(
+ '',
+ '',
+ ),
+
+ // RCDATA elements (TITLE, TEXTAREA): Content is processed but placeholder
+ // patterns are not recognized - they're treated as literal text and escaped.
+ 'TITLE content placeholder escaped' => array(
+ 'Hello %name> ',
+ 'Hello </%name> ',
+ ),
+
+ 'TEXTAREA content placeholder escaped' => array(
+ '',
+ '',
+ ),
+ );
+ }
+
+ /**
+ * 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 ::render
+ */
+ public function test_pre_element_leading_newline_behavior( string $template_string, array $replacements, string $expected ) {
+ if (
+ "\n" === $replacements['replacement'][0] ||
+ "\r" === $replacements['replacement'][0]
+ ) {
+ $this->markTestSkipped( 'PRE leading newline handling is not yet correct.' );
+ }
+
+ $result = T::render( $template_string, $replacements );
+ $this->assertEqualHTML( $expected, $result );
+ }
+
+ public static function data_pre_element_leading_newline() {
+ return array(
+ 'PRE without newline' => array(
+ '%replacement> ',
+ array( 'replacement' => "line1\nline2" ),
+ "line1\nline2 ",
+ ),
+
+ 'PRE with newline' => array(
+ "\n%replacement> ",
+ array( 'replacement' => "line1\nline2" ),
+ "line1\nline2 ",
+ ),
+
+ 'PRE with newline in replacement' => array(
+ "\n%replacement> ",
+ array( 'replacement' => "line1\nline2" ),
+ "line1\nline2 ",
+ ),
+
+ 'PRE with newline and newline in replacement' => array(
+ "\n%replacement> ",
+ array( 'replacement' => "\nline1\nline2" ),
+ "\n\nline1\nline2 ",
+ ),
+
+ 'PRE with newline, newline replacement, and additional contents' => array(
+ "\n%replacement> ",
+ array( 'replacement' => "\nline1" ),
+ "\n\nline1 ",
+ ),
+ );
+ }
+
+ /**
+ * Verifies render() warns on missing replacement key.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ *
+ * @expectedIncorrectUsage WP_HTML_Template::render
+ */
+ public function test_render_warns_on_missing_key() {
+ T::render( '%name> %age>
', array( 'name' => 'Alice' ) );
+ }
+
+ /**
+ * Verifies render() warns on unused replacement key.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ *
+ * @expectedIncorrectUsage WP_HTML_Template::render
+ */
+ public function test_render_warns_on_unused_key() {
+ T::render(
+ '%name>
',
+ array(
+ 'name' => 'Alice',
+ 'extra' => 'ignored',
+ )
+ );
+ }
+
+ /**
+ * Verifies render() warns when template used in attribute context.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ * @covers ::template
+ *
+ * @expectedIncorrectUsage WP_HTML_Template::render
+ */
+ public function test_render_warns_on_template_in_attribute_context() {
+ T::render(
+ ' ',
+ array( 'html' => T::template( 'nested ' ) )
+ );
+ }
+
+ /**
+ * Verifies that static text around placeholders in attributes is escaped.
+ *
+ * @ticket 60229
+ *
+ * @dataProvider data_escapes_static_text_around_placeholder_in_attribute
+ *
+ * @covers ::render
+ */
+ public function test_escapes_static_text_around_placeholder_in_attribute( string $template_string, array $replacements, string $expected ) {
+ $result = T::render( $template_string, $replacements );
+ $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' => '' ),
+ ' ',
+ ),
+
+ 'two placeholders in href' => array(
+ 'link ',
+ array(
+ 'base' => '/posts',
+ 'slug' => 'hello-world',
+ ),
+ 'link ',
+ ),
+
+ 'three placeholders building URL' => array(
+ 'link ',
+ array(
+ 'scheme' => 'https',
+ 'host' => 'example.com',
+ 'path' => 'page',
+ ),
+ 'link ',
+ ),
+
+ 'adjacent placeholders (no separator)' => array(
+ ' ',
+ array(
+ 'a' => 'Hello',
+ 'b' => 'World',
+ ),
+ ' ',
+ ),
+
+ 'placeholders with static text between' => array(
+ 'link ',
+ array(
+ 'base' => '/search',
+ 'page' => '2',
+ 'sort' => 'date',
+ ),
+ 'link ',
+ ),
+
+ 'same placeholder repeated in attribute' => array(
+ ' ',
+ array( 'val' => 'test' ),
+ ' ',
+ ),
+
+ 'escaping in multiple placeholders' => array(
+ 'link ',
+ array(
+ 'base' => '/search',
+ 'query' => 'a&b"d',
+ ),
+ 'link ',
+ ),
+
+ 'multiple placeholders across multiple attributes' => array(
+ 'link ',
+ array(
+ 'url' => '/page',
+ 'a' => 'Alice',
+ 'b' => 'Bob',
+ ),
+ 'link ',
+ ),
+ );
+ }
+
+ /**
+ * @ticket 60229
+ *
+ * @expectedIncorrectUsage WP_HTML_Template::render
+ */
+ public function test_warns_on_unrecognized_replacements() {
+ T::render( ' ', array( 'extra' => 'oops' ) );
+ }
+
+ /**
+ * @ticket 60229
+ *
+ * @expectedIncorrectUsage WP_HTML_Template::render
+ */
+ public function test_warns_on_omit_replacement() {
+ T::render( '% omitted >
', array( 'other' => 'value' ) );
+ }
+
+ /**
+ * Verifies that boolean true creates a boolean attribute.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ */
+ public function test_boolean_true_creates_boolean_attribute() {
+ $result = T::render(
+ ' ',
+ array( 'disabled' => true )
+ );
+ $this->assertEqualHTML( ' ', $result );
+ }
+
+ /**
+ * Verifies that boolean false removes the attribute.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ */
+ public function test_boolean_false_removes_attribute() {
+ $result = T::render(
+ ' ',
+ array( 'disabled' => false )
+ );
+ $this->assertEqualHTML( ' ', $result );
+ }
+
+ /**
+ * Verifies that null removes the attribute (same as false).
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ */
+ public function test_null_removes_attribute() {
+ $result = T::render(
+ ' ',
+ array( 'class' => null )
+ );
+ $this->assertEqualHTML( ' ', $result );
+ }
+
+ /**
+ * Verifies that boolean with partial placeholder returns false.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ */
+ public function test_partial_placeholder_rejects_boolean() {
+ $result = T::render(
+ ' ',
+ array( 'suffix' => true )
+ );
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * @ticket 60229
+ *
+ * @dataProvider data_boolean_attribute_handling
+ *
+ * @covers ::render
+ */
+ public function test_boolean_attribute_handling( string $template, array $replacements, string $expected ) {
+ $result = T::render( $template, $replacements );
+ $this->assertEqualHTML( $expected, $result );
+ }
+
+ public static function data_boolean_attribute_handling() {
+ return array(
+ 'true creates boolean attribute' => array(
+ ' ',
+ array( 'disabled' => true ),
+ ' ',
+ ),
+
+ 'false removes attribute' => array(
+ ' ',
+ array( 'disabled' => false ),
+ ' ',
+ ),
+
+ 'null removes attribute' => array(
+ ' ',
+ array( 'class' => null ),
+ ' ',
+ ),
+
+ 'empty string keeps attribute with empty value' => array(
+ ' ',
+ array( 'value' => '' ),
+ ' ',
+ ),
+
+ 'mixed boolean and string replacements' => array(
+ ' ',
+ array(
+ 'd' => true,
+ 'v' => 'test',
+ ),
+ ' ',
+ ),
+
+ 'multiple attributes, one removed' => array(
+ ' ',
+ array(
+ 'c' => false,
+ 'i' => 'my-id',
+ ),
+ ' ',
+ ),
+
+ 'single-quoted attribute with boolean' => array(
+ " ",
+ array( 'disabled' => true ),
+ ' ',
+ ),
+ );
+ }
+
+ /**
+ * Verifies render() returns false for integer replacement value.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ *
+ * @expectedIncorrectUsage WP_HTML_Template::render
+ */
+ public function test_render_returns_false_for_integer_replacement() {
+ $result = T::render( '%val>
', array( 'val' => 123 ) );
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Verifies render() returns false for array replacement value.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ *
+ * @expectedIncorrectUsage WP_HTML_Template::render
+ */
+ public function test_render_returns_false_for_array_replacement() {
+ $result = T::render( '%val>
', array( 'val' => array( 'a', 'b' ) ) );
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Verifies render() returns false for object replacement value without __toString.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ *
+ * @expectedIncorrectUsage WP_HTML_Template::render
+ */
+ public function test_render_returns_false_for_object_replacement() {
+ $result = T::render( '%val>
', array( 'val' => new stdClass() ) );
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Verifies render() returns false for null replacement value.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ *
+ * @expectedIncorrectUsage WP_HTML_Template::render
+ */
+ public function test_render_returns_false_for_null_replacement() {
+ $result = T::render( '%val>
', array( 'val' => null ) );
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Verifies render() returns false for boolean replacement value.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ *
+ * @expectedIncorrectUsage WP_HTML_Template::render
+ */
+ public function test_render_returns_false_for_boolean_replacement() {
+ $result = T::render( '%val>
', array( 'val' => true ) );
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Verifies that duplicate attributes after the placeholder are removed with false.
+ *
+ * HTML may contain duplicate attributes (e.g., from user error or generated HTML).
+ * When false removes the placeholder attribute, duplicates should also be removed.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ */
+ public function test_duplicate_attribute_removed_with_false() {
+ // The "disabled" attribute appears twice: once with placeholder, once as boolean.
+ $result = T::render(
+ ' ',
+ array( 'd' => false )
+ );
+ // Both occurrences of "disabled" should be removed.
+ $this->assertEqualHTML( ' ', $result );
+ }
+
+ /**
+ * Verifies that duplicate attributes after the placeholder are removed with null.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ */
+ public function test_duplicate_attribute_removed_with_null() {
+ $result = T::render(
+ ' ',
+ array( 'c' => null )
+ );
+ $this->assertEqualHTML( ' ', $result );
+ }
+
+ /**
+ * Verifies that multiple duplicate attributes are all removed with false.
+ *
+ * When an attribute appears more than twice, all occurrences should be removed.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ */
+ public function test_multiple_duplicate_attributes_removed() {
+ // Three occurrences of "disabled": placeholder + two duplicates.
+ $result = T::render(
+ ' ',
+ array( 'd' => false )
+ );
+ $this->assertEqualHTML( ' ', $result );
+ }
+
+ /**
+ * Test that a template replacement cannot template structure HTML.
+ *
+ * @ticket 60229
+ *
+ * @covers ::render
+ */
+ public function test_replacements_cannot_modify_template_structure() {
+ $this->markTestSkipped( 'Template HTML structure protection is not implemented.' );
+
+ // Three occurrences of "disabled": placeholder + two duplicates.
+ $result = T::render(
+ '%link-text> ',
+ array( 'link-text' => WP_HTML_Template::template( 'A elements cannot nest in HTML ' ) )
+ );
+ $this->assertFalse( $result, 'Should have rejected the template with an invalid replacement.' );
+ }
+}