Skip to content

Commit 5c1b77d

Browse files
committed
HTML API: Introduce HTML Template Renderer
Currently only renders text data: - does not render nested HTML (escapes everything) - does not escape URLs
1 parent cfd5b54 commit 5c1b77d

File tree

4 files changed

+144
-2
lines changed

4 files changed

+144
-2
lines changed

src/wp-includes/html-api/class-wp-html-tag-processor.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1972,8 +1972,8 @@ private function after_tag() {
19721972
$this->token_length = null;
19731973
$this->tag_name_starts_at = null;
19741974
$this->tag_name_length = null;
1975-
$this->text_starts_at = 0;
1976-
$this->text_length = 0;
1975+
$this->text_starts_at = null;
1976+
$this->text_length = null;
19771977
$this->is_closing_tag = null;
19781978
$this->attributes = array();
19791979
$this->duplicate_attributes = null;
@@ -2669,6 +2669,10 @@ public function get_token_name() {
26692669
* @return string
26702670
*/
26712671
public function get_modifiable_text() {
2672+
if ( null === $this->text_starts_at ) {
2673+
return '';
2674+
}
2675+
26722676
$at = $this->text_starts_at;
26732677
$length = $this->text_length;
26742678
$text = substr( $this->html, $at, $length );
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
/**
4+
* WP_HTML_Template class.
5+
*
6+
* @since 6.5.0
7+
*/
8+
class WP_HTML_Template extends WP_HTML_Tag_Processor {
9+
/**
10+
* Renders an HTML template, replacing the placeholders with the provided values.
11+
*
12+
* @since 6.5.0
13+
*
14+
* @param string $template The HTML template.
15+
* @param string $args Array of key/value pairs providing substitue values for the placeholders.
16+
* @return string The rendered HTML.
17+
*/
18+
public static function render( $template, $args = array() ) {
19+
$processor = new self( $template );
20+
while ( $processor->next_token() ) {
21+
$type = $processor->get_token_type();
22+
$text = $processor->get_modifiable_text();
23+
24+
if ( '#funky-comment' === $type && strlen( $text ) > 0 && '%' === $text[0] ) {
25+
$name = substr( $text, 1 );
26+
$value = isset( $args[ $name ] ) && is_string( $args[ $name ] ) ? $args[ $name ] : null;
27+
$processor->set_bookmark( 'here' );
28+
$processor->lexical_updates[] = new WP_HTML_Text_Replacement(
29+
$processor->bookmarks['here']->start,
30+
$processor->bookmarks['here']->length,
31+
null === $value ? '' : esc_html( $value )
32+
);
33+
}
34+
35+
if ( '#tag' === $type ) {
36+
foreach ( $processor->get_attribute_names_with_prefix( '' ) ?? array() as $attribute_name ) {
37+
if ( str_starts_with( $attribute_name, '...' ) ) {
38+
$spread_name = substr( $attribute_name, 3 );
39+
if ( isset( $args[ $spread_name ] ) && is_array( $args[ $spread_name ] ) ) {
40+
foreach ( $args[ $spread_name ] as $key => $value ) {
41+
if ( true === $value || null === $value || is_string( $value ) ) {
42+
$processor->set_attribute( $key, $value );
43+
}
44+
}
45+
}
46+
$processor->remove_attribute( $attribute_name );
47+
}
48+
49+
$value = $processor->get_attribute( $attribute_name );
50+
51+
if ( ! is_string( $value ) ) {
52+
continue;
53+
}
54+
55+
$full_match = null;
56+
if ( preg_match( '~^</%([^>]+)>$~', $value, $full_match ) ) {
57+
$name = $full_match[1];
58+
59+
if ( array_key_exists( $name, $args ) ) {
60+
$value = $args[ $name ];
61+
if ( null === $value ) {
62+
$processor->remove_attribute( $attribute_name );
63+
} elseif ( true === $value ) {
64+
$processor->set_attribute( $attribute_name, true );
65+
} elseif ( is_string( $value ) ) {
66+
$processor->set_attribute( $attribute_name, esc_attr( $args[ $name ] ) );
67+
} else {
68+
$processor->remove_attribute( $attribute_name );
69+
}
70+
} else {
71+
$processor->remove_attribute( $attribute_name );
72+
}
73+
74+
continue;
75+
}
76+
77+
$new_value = preg_replace_callback(
78+
'~</%([^>]+)>~',
79+
static function ( $matches ) use ( $args ) {
80+
return is_string( $args[ $matches[1] ] )
81+
? esc_attr( $args[ $matches[1] ] )
82+
: '';
83+
},
84+
$value
85+
);
86+
87+
if ( $new_value !== $value ) {
88+
$processor->set_attribute( $attribute_name, $new_value );
89+
}
90+
}
91+
}
92+
}
93+
94+
return $processor->get_updated_html();
95+
}
96+
}

src/wp-settings.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@
245245
require ABSPATH . WPINC . '/html-api/class-wp-html-token.php';
246246
require ABSPATH . WPINC . '/html-api/class-wp-html-processor-state.php';
247247
require ABSPATH . WPINC . '/html-api/class-wp-html-processor.php';
248+
require ABSPATH . WPINC . '/html-api/class-wp-html-template.php';
248249
require ABSPATH . WPINC . '/class-wp-http.php';
249250
require ABSPATH . WPINC . '/class-wp-http-streams.php';
250251
require ABSPATH . WPINC . '/class-wp-http-curl.php';
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
/**
3+
* Unit tests covering WP_HTML_Template functionality.
4+
*
5+
* @package WordPress
6+
* @subpackage HTML-API
7+
*
8+
* @since 6.5.0
9+
*
10+
* @group html-api
11+
*
12+
* @coversDefaultClass WP_HTML_Template
13+
*/
14+
15+
class Tests_HtmlApi_WpHtmlTemplate extends WP_UnitTestCase {
16+
/**
17+
* Demonstrates how to pass values into an HTML template.
18+
*
19+
* @ticket {TICKET_NUMBER}
20+
*/
21+
public function test_basic_render() {
22+
$html = WP_HTML_Template::render(
23+
'<div class="is-test </%class>" ...div-args inert="</%is_inert>">Just a </%count> test</div>',
24+
array(
25+
'count' => '<strong>Hi <3</strong>',
26+
'class' => '5>4',
27+
'is_inert' => 'inert',
28+
'div-args' => array(
29+
'class' => 'hoover',
30+
'disabled' => true,
31+
),
32+
)
33+
);
34+
35+
$this->assertSame(
36+
'<div disabled class="hoover" inert="inert">Just a &lt;strong&gt;Hi &lt;3&lt;/strong&gt; test</div>',
37+
$html,
38+
'Failed to properly render template.'
39+
);
40+
}
41+
}

0 commit comments

Comments
 (0)