diff --git a/src/wp-includes/html-api/class-wp-html-template.php b/src/wp-includes/html-api/class-wp-html-template.php new file mode 100644 index 0000000000000..8c526829a8267 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-html-template.php @@ -0,0 +1,612 @@ + + */ + private const ESCAPE_MAP = array( + '&' => '&', + '<' => '<', + '>' => '>', + "'" => ''', + '"' => '"', + ); + + /** + * The template string. + * + * @since 7.1.0 + * + * @var string + */ + private string $template_string; + + /** + * The replacement values for placeholders. + * + * @since 7.1.0 + * + * @var array + */ + private array $replacements; + + private function __construct( string $template_string, array $replacements ) { + $this->template_string = $template_string; + $this->replacements = $replacements; + } + + /** + * Creates a bound template for use as a replacement value in another template. + * + * This is a data container — it stores the template string and replacements + * without parsing or processing. Processing happens when the parent template + * is rendered, which provides the correct parsing context. + * + * @since 7.1.0 + * + * @param string $template The template string with placeholders. + * @param array $replacements The replacement values for placeholders. + * @return static The bound template instance. + */ + public static function template( string $template, array $replacements = array() ) { + return new static( $template, $replacements ); + } + + /** + * Renders a template to an HTML string. + * + * Processes the template in a single pass using WP_HTML_Processor. Placeholders + * (``) in text context are replaced with escaped values. Placeholders in + * attribute values are found via regex scanning and replaced with escaped values. + * + * Nested templates (WP_HTML_Template values) are parsed at render time in the + * parent's parsing context, enabling correct handling of table elements and + * other context-dependent HTML. + * + * @since 7.1.0 + * + * @param string $template The template string with placeholders. + * @param array $replacements The replacement values for placeholders. + * @return string|false The rendered HTML, or false on error. + */ + public static function render( string $template, array $replacements = array() ) { + if ( empty( $replacements ) ) { + return WP_HTML_Processor::normalize( $template ) ?? false; + } + + $instance = new static( $template, $replacements ); + $processor = static::create_processor( $template ); + if ( null === $processor ) { + return false; + } + + return static::process( $instance, $processor ); + } + + /** + * Creates an extended WP_HTML_Processor that exposes internals needed for template processing. + * + * @since 7.1.0 + * + * @param string $html The HTML fragment to parse. + * @return WP_HTML_Processor|null The processor, or null on failure. + */ + private static function create_processor( string $html ) { + return ( new class( '', WP_HTML_Processor::CONSTRUCTOR_UNLOCK_CODE ) extends WP_HTML_Processor { + public function get_html(): string { + return $this->html; + } + + public function get_tag_attributes(): array { + return Closure::bind( fn () => $this->attributes, $this, WP_HTML_Tag_Processor::class )(); + } + + /** + * Creates a fragment processor using the given element as context. + * + * For most elements, uses standard body context parsing. For table-related + * elements, creates a properly-nested wrapper to establish the correct + * insertion mode (e.g., IN_TABLE_BODY for content inside ``). + * + * @param string $html The HTML fragment to parse. + * @param string $context_element The context element tag name (e.g., 'TBODY'). + * @return static|null The fragment processor, or null on failure. + */ + public function create_fragment_for_context( string $html, string $context_element ): ?static { + /* + * Table-related elements need special context to parse correctly. + * Build a wrapper that creates the right insertion mode, then use + * create_fragment_at_current_node() to parse the child HTML. + */ + $table_contexts = array( + 'TABLE' => '', + 'THEAD' => '
', + 'TBODY' => '
', + 'TFOOT' => '
', + 'TR' => '
', + 'TD' => '
', + 'TH' => '', $result ); + $this->assertStringContainsString( '', $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(
+				'
', + array( 'replacement' => "line1\nline2" ), + "
line1\nline2
", + ), + + 'PRE with newline' => array( + "
\n
", + array( 'replacement' => "line1\nline2" ), + "
line1\nline2
", + ), + + 'PRE with newline in replacement' => array( + "
\n
", + array( 'replacement' => "line1\nline2" ), + "
line1\nline2
", + ), + + 'PRE with newline and newline in replacement' => array( + "
\n
", + array( 'replacement' => "\nline1\nline2" ), + "
\n\nline1\nline2
", + ), + + 'PRE with newline, newline replacement, and additional contents' => array( + "
\n
", + 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( '

', 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( + '

', + 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( '

', 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( '

', 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( '

', 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( '

', 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( '

', 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( '

', 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( + '', + 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.' ); + } +}
', + 'CAPTION' => '/', $result, 'Table row element should be preserved.' ); + $this->assertStringContainsString( '', $result ); + $this->assertStringContainsString( '', $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( + '', + array( + 'col1' => 'Name', + 'col2' => 'Value', + ) + ); + + $row = WP_HTML_Template::template( + '', + array( + 'name' => 'Alice', + 'value' => '42', + ) + ); + + $result = WP_HTML_Template::render( + '
', + '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, '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, '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}"; + } + + 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( + '#^$#i', + $raw_value + ); + + while ( + 1 === preg_match( + '##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 = '
'; + $replacements = array( + '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, !

', + array( 'name' => 'World' ), + '

Hello, World!

', + ), + + 'escapes special characters in text' => array( + '

Hello, !

', + array( 'placeholder' => 'Alice & Bob' ), + '

Hello, Alice & Bob!

', + ), + + 'escapes angle brackets in text' => array( + '

Hello, !

', + array( 'name' => '' ), + '

Hello, <little-bobby-tags>!

', + ), + + 'repeated placeholders' => array( + '

, , , & !

', + array( + 'a' => 'Alice', + 'name' => 'Bob', + ), + '

Alice, Alice, Bob, & Bob!

', + ), + + 'nested template replacement' => array( + '

Hello, ', + 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' + </%alt> + 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( + '', + 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\n", + array( + 'url' => 'https://example.com/hello-world/?foo=1&bar=2', + 'target' => '_blank', + 'title' => WP_HTML_Template::template( + '\'\' & ""', + 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: .', + 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: + 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: + 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 . + 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 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( + ' ', + array( + 'id' => 'user_name', + ), + ' ', + ); + + // Named placeholders + yield 'named placeholders' => array( + ' by ', + 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( + '
', + array( + 'icon' => WP_HTML_Template::template( '' ), + 'message' => 'Something went wrong.', + ), + '
Something went wrong.
', + ); + + // Empty replacement + yield 'empty replacement value' => array( + '

Hello

', + array( + 'suffix' => '', + ), + '

Hello

', + ); + + // HTML entities in template (should be preserved) + yield 'HTML entities in template' => array( + '

', + 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' + + 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( 'John Doe' ), + ), + <<<'HTML' + John Doe + 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( + "
\n
", + array( + 'term' => "Term \"{$i}\"", + 'definition' => WP_HTML_Template::template( + 'IYKYK: ', + array( + 'i' => (string) $i, + 'expansion' => '"If You Know You Know"', + ) + ), + ) + ); + } + + $result = WP_HTML_Template::render( + <<<'HTML' +
+ + + +
+ 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( + '
', + 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( '
HelloWorld
', + array( + 'header' => $header, + 'row' => $row, + ) + ); + + $this->assertStringContainsString( '
NameValue
Alice42