Skip to content

Commit b0701b6

Browse files
committed
WIP: Add HTML templating
1 parent 4de4b72 commit b0701b6

File tree

1 file changed

+228
-0
lines changed

1 file changed

+228
-0
lines changed
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
<?php
2+
3+
class WP_HTML_Template {
4+
/**
5+
* Wraps HTML in a class indicating that it was generated from within
6+
* the HTML API. Used for safely merging HTML of varying provenance,
7+
* where miscoordination could otherwise result in double-encoding.
8+
*
9+
* @since 7.0
10+
* @access private
11+
*/
12+
private static $sentinel_class = null;
13+
14+
/**
15+
* @param string $template
16+
* @param array|null $args
17+
* @return object Rendered and wrapped HTML.
18+
*/
19+
public static function compile( string $template, ?array $args = null ) {
20+
self::ensure_sentinel();
21+
22+
$builder = new class ( '', WP_HTML_Processor::CONSTRUCTOR_UNLOCK_CODE ) extends WP_HTML_Processor {
23+
public $deferred_updates = array();
24+
25+
public function get_raw_attribute( $name ) {
26+
$lexical_updates = $this->lexical_updates;
27+
$this->remove_attribute( $name );
28+
$span = $this->lexical_updates[ strtolower( $name ) ];
29+
$this->lexical_updates = $lexical_updates;
30+
$span->text = substr( $this->html, $span->start, $span->length );
31+
return $span;
32+
}
33+
34+
public function raw_replace_token( $content ) {
35+
$this->set_bookmark( 'here' );
36+
$here = $this->bookmarks['_here'];
37+
$this->deferred_updates[] = new WP_HTML_Text_Replacement(
38+
$here->start,
39+
$here->length,
40+
$content
41+
);
42+
}
43+
44+
public function set_attribute( $name, $value ): bool {
45+
if ( ! parent::set_attribute( $name, $value ) ) {
46+
return false;
47+
}
48+
$lower_name = strtolower( $name );
49+
$this->deferred_updates[ $lower_name ] = $this->lexical_updates[ $lower_name ];
50+
return true;
51+
}
52+
53+
public function remove_attribute( $name ): bool {
54+
if ( ! parent::remove_attribute( $name ) ) {
55+
return false;
56+
}
57+
58+
$lower_name = strtolower( $name );
59+
$this->deferred_updates[ $lower_name ] = $this->lexical_updates[ $lower_name ];
60+
return true;
61+
}
62+
};
63+
64+
$processor = $builder::create_fragment( $template );
65+
$bit_pattern = '%(?P<VAR>[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)';
66+
67+
$refers_to = function ( $input ) use ( $args, $bit_pattern ){
68+
if ( 1 === preg_match( "~^</{$bit_pattern}>$~", $input, $matches ) ) {
69+
return isset( $args[ $matches['VAR'] ] ) ? $matches['VAR'] : null;
70+
}
71+
72+
return false;
73+
};
74+
75+
while ( $processor->next_token() ) {
76+
$token_type = $processor->get_token_type();
77+
78+
// Skip over entire elements when instructed.
79+
if ( '#tag' === $token_type ) {
80+
if ( is_string( $processor->get_attribute( 'data-wp-if' ) ) ) {
81+
$ignorable = $processor->get_raw_attribute( 'data-wp-if' )->text;
82+
$quote = $ignorable[ strlen( $ignorable ) - 1 ];
83+
$ignorable = substr(
84+
$ignorable,
85+
strcspn( $ignorable, $quote ) + 1,
86+
-1
87+
);
88+
$processor->remove_attribute( 'data-wp-if' );
89+
$condition = $refers_to( $ignorable );
90+
91+
if ( isset( $condition, $args[ $condition ] ) && in_array( $args[ $condition ], array( false, null, '' ), true ) ) {
92+
$depth = $processor->get_current_depth();
93+
while ( $processor->next_token() && $processor->get_current_depth() > $depth ) {
94+
continue;
95+
}
96+
97+
continue;
98+
}
99+
}
100+
101+
// Replace Bits in attributes and spread attributes.
102+
foreach ( $processor->get_attribute_names_with_prefix( '' ) ?? array() as $name ) {
103+
// Spread attributes which are boolean; don’t replace those with values.
104+
if ( str_starts_with( $name, '...' ) && true === $processor->get_attribute( $name ) ) {
105+
$processor->remove_attribute( $name );
106+
$spread_name = substr( $name, 3 );
107+
if ( isset( $spread_name, $args[ $spread_name ] ) && is_array( $args[ $spread_name ] ) ) {
108+
$spread_args = $args[ $spread_name ];
109+
foreach ( $spread_args as $arg_name => $arg_value ) {
110+
if ( is_string( $arg_name ) && ( true === $arg_value || is_string( $arg_value ) ) ) {
111+
$processor->set_attribute( $arg_name, $arg_value );
112+
} else if ( is_string( $arg_name ) && in_array( $arg_value, array( false, null ), true ) ) {
113+
$processor->remove_attribute( $arg_name );
114+
}
115+
}
116+
}
117+
118+
continue;
119+
}
120+
121+
$raw_attr = $processor->get_raw_attribute( $name )->text;
122+
$last_c = $raw_attr[ strlen( $raw_attr ) - 1 ];
123+
124+
// Bit syntax cannot appear in unquoted attributes.
125+
if ( '"' !== $last_c && "'" !== $last_c ) {
126+
continue;
127+
}
128+
129+
$value = substr( $raw_attr, strcspn( $raw_attr, $last_c ) + 1, -1 );
130+
$matches = null;
131+
$bits = preg_match_all( "~</{$bit_pattern}>~", $value, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE );
132+
if ( 0 === $bits || false === $bits ) {
133+
continue;
134+
}
135+
136+
$updated = array();
137+
$was_at = 0;
138+
foreach ( $matches as $match ) {
139+
$updated[] = substr( $value, $was_at, $match[0][1] - $was_at );
140+
$was_at = $match[0][1] + strlen( $match[0][0] );
141+
$arg_name = $match['VAR'][0];
142+
143+
if ( isset( $arg_name, $args[ $arg_name ] ) && is_string( $args[ $arg_name ] ) ) {
144+
$updated[] = self::escape( $name, $args[ $arg_name ] );
145+
}
146+
}
147+
148+
$updated[] = substr( $value, $was_at );
149+
$decoded = WP_HTML_Decoder::decode_attribute( implode( '', $updated ) );
150+
if ( ! $processor->set_attribute( $name, $decoded ) ) {
151+
$processor->remove_attribute( $name );
152+
}
153+
}
154+
}
155+
156+
if ( '#funky-comment' === $token_type ) {
157+
$text = $processor->get_modifiable_text();
158+
if ( 1 !== preg_match( "~^{$bit_pattern}$~", $text, $match ) ) {
159+
continue;
160+
}
161+
162+
if ( isset( $match['VAR'], $args[ $match['VAR'] ] ) && is_string( $args[ $match['VAR'] ] ) ) {
163+
$processor->raw_replace_token( self::escape( null, $args[ $match['VAR'] ] ) );
164+
}
165+
}
166+
}
167+
168+
return self::$sentinel_class::wrap( $template, $processor->deferred_updates );
169+
}
170+
171+
public static function render( $compiled ): string {
172+
return $compiled->unwrap();
173+
}
174+
175+
private static function escape( $attr_name, string $plaintext ): string {
176+
if ( isset( $attr_name ) && in_array( strtolower( $attr_name ), wp_kses_uri_attributes(), true ) ) {
177+
return esc_url( $plaintext );
178+
}
179+
180+
return strtr(
181+
$plaintext,
182+
array(
183+
'<' => '&lt;',
184+
'>' => '&gt;',
185+
'&' => '&amp;',
186+
'"' => '&quot;',
187+
"'" => '&apos;',
188+
)
189+
);
190+
}
191+
192+
/**
193+
* Ensures that the sentinel class is dynamically generated at boot.
194+
* This class is to never be serialized or instantiated outside of
195+
* this parent class.
196+
*
197+
* @since 7.0
198+
*/
199+
private static function ensure_sentinel(): void {
200+
if ( isset( self::$sentinel_class ) ) {
201+
return;
202+
}
203+
204+
self::$sentinel_class = new class () {
205+
private $html = '';
206+
207+
private $updates = array();
208+
209+
public static function wrap( string $html, array $updates ): self {
210+
$wrapper = new self();
211+
$wrapper->html = $html;
212+
$wrapper->updates = $updates;
213+
return $wrapper;
214+
}
215+
216+
public function unwrap(): string {
217+
$processor = new class( $this->html ) extends WP_HTML_Tag_Processor {
218+
public function flood( $updates ) {
219+
$this->lexical_updates = $updates;
220+
}
221+
};
222+
223+
$processor->flood( $this->updates );
224+
return $processor->get_updated_html();
225+
}
226+
};
227+
}
228+
}

0 commit comments

Comments
 (0)