@@ -26,6 +26,7 @@ final class WP_Interactivity_API {
2626 * 'data-wp-class': 'data_wp_class_processor',
2727 * 'data-wp-style': 'data_wp_style_processor',
2828 * 'data-wp-text': 'data_wp_text_processor',
29+ * 'data-wp-html': 'data_wp_html_processor',
2930 * 'data-wp-each': 'data_wp_each_processor',
3031 * }
3132 */
@@ -37,6 +38,12 @@ final class WP_Interactivity_API {
3738 'data-wp-class ' => 'data_wp_class_processor ' ,
3839 'data-wp-style ' => 'data_wp_style_processor ' ,
3940 'data-wp-text ' => 'data_wp_text_processor ' ,
41+ /*
42+ * `data-wp-html` is processed after `data-wp-text`, which it cannot be
43+ * combined with, and before `data-wp-each`, so that it resolves its
44+ * reference in the namespace and context its own element declares.
45+ */
46+ 'data-wp-html ' => 'data_wp_html_processor ' ,
4047 /*
4148 * `data-wp-each` needs to be processed in the last place because it moves
4249 * the cursor to the end of the processed items to prevent them to be
@@ -144,6 +151,25 @@ final class WP_Interactivity_API {
144151 */
145152 private $ current_element = null ;
146153
154+ /**
155+ * Marker that makes the placeholders of the current `process_directives()`
156+ * call recognizable. It occurs nowhere in the HTML that call received.
157+ *
158+ * @since 7.2.0
159+ * @var string
160+ */
161+ private $ html_placeholder_marker = '' ;
162+
163+ /**
164+ * Trusted HTML written by `data-wp-html` during the current
165+ * `process_directives()` call, indexed by the placeholder comment standing
166+ * in for it until the call has finished.
167+ *
168+ * @since 7.2.0
169+ * @var array<string, string>
170+ */
171+ private $ html_placeholders = array ();
172+
147173 /**
148174 * Gets and/or sets the initial state of an Interactivity API store for a
149175 * given namespace.
@@ -455,6 +481,8 @@ public function add_client_navigation_support_to_script_module( string $script_m
455481 * and updates the markup accordingly.
456482 *
457483 * @since 6.5.0
484+ * @since 7.2.0 The output includes the trusted HTML that `data-wp-html` renders. That HTML is
485+ * added once the whole call has finished, so it is never processed.
458486 *
459487 * @param string $html The HTML content to process.
460488 * @return string The processed HTML content. It returns the original content when the HTML contains unbalanced tags.
@@ -467,12 +495,34 @@ public function process_directives( string $html ): string {
467495 $ this ->namespace_stack = array ();
468496 $ this ->context_stack = array ();
469497
498+ /*
499+ * The trusted HTML of `data-wp-html` hosts never takes part in the
500+ * processing: each host receives a placeholder comment, and the comments
501+ * are replaced by their HTML once the result is known. The marker that
502+ * identifies them is chosen so that it occurs nowhere in the input, which
503+ * makes it impossible for the input to contain a placeholder.
504+ */
505+ $ marker = 'wp-interactivity-dangerous-html ' ;
506+ while ( str_contains ( $ html , $ marker ) ) {
507+ $ marker .= '_ ' ;
508+ }
509+ $ this ->html_placeholder_marker = $ marker ;
510+ $ this ->html_placeholders = array ();
511+
470512 $ result = $ this ->_process_directives ( $ html );
471513
472514 $ this ->namespace_stack = null ;
473515 $ this ->context_stack = null ;
474516
475- return $ result ?? $ html ;
517+ $ placeholders = $ this ->html_placeholders ;
518+ $ this ->html_placeholders = array ();
519+
520+ if ( null === $ result ) {
521+ return $ html ;
522+ }
523+
524+ // `strtr()` never searches the replacements it has inserted.
525+ return strtr ( $ result , $ placeholders );
476526 }
477527
478528 /**
@@ -656,6 +706,7 @@ private function _process_directives( string $html ) {
656706 * @since 6.6.0 Removed `default_namespace` and `context` arguments.
657707 * @since 6.6.0 Add support for derived state.
658708 * @since 6.9.0 Receive $entry as an argument instead of the directive value string.
709+ * @since 7.2.0 Tracks the path of a token that marks HTML as trusted, reached through state.
659710 *
660711 * @param array $entry An array containing a whole directive entry with its namespace, value, suffix, or unique ID.
661712 * @return mixed|null The result of the evaluation. Null if the reference path doesn't exist or the namespace is falsy.
@@ -681,8 +732,9 @@ private function evaluate( $entry ) {
681732 $ path = $ should_negate_value ? substr ( $ path , 1 ) : $ path ;
682733
683734 // Extracts the value from the store using the reference path.
684- $ path_segments = explode ( '. ' , $ path );
685- $ current = $ store ;
735+ $ path_segments = explode ( '. ' , $ path );
736+ $ current = $ store ;
737+ $ called_closure = false ;
686738 foreach ( $ path_segments as $ index => $ path_segment ) {
687739 /*
688740 * Special case for numeric arrays and strings. Add length
@@ -731,7 +783,8 @@ private function evaluate( $entry ) {
731783 */
732784 array_push ( $ this ->namespace_stack , $ ns );
733785 try {
734- $ current = $ current ();
786+ $ called_closure = true ;
787+ $ current = $ current ();
735788
736789 /*
737790 * Tracks derived state properties that are accessed during
@@ -765,6 +818,28 @@ private function evaluate( $entry ) {
765818 }
766819 }
767820
821+ /*
822+ * Tracks the tokens that mark HTML as trusted and that a reference
823+ * reaches through state, the way derived state closures are tracked, so
824+ * the client keeps the server-rendered content of a token until its
825+ * store supplies a value. A token reached through context, or through
826+ * the result of a derived state closure, is not tracked: the client has
827+ * no context store to resolve it from, and the closure is tracked
828+ * instead.
829+ *
830+ * @since 7.2.0
831+ */
832+ if (
833+ 'state ' === $ path_segments [0 ] &&
834+ ! $ called_closure &&
835+ $ current instanceof WP_Interactivity_Dangerous_HTML
836+ ) {
837+ $ this ->derived_state_closures [ $ ns ] ??= array ();
838+ if ( ! in_array ( $ path , $ this ->derived_state_closures [ $ ns ], true ) ) {
839+ $ this ->derived_state_closures [ $ ns ][] = $ path ;
840+ }
841+ }
842+
768843 // Returns the opposite if it contains a negation operator (!).
769844 return $ should_negate_value ? ! $ current : $ current ;
770845 }
@@ -1414,6 +1489,111 @@ private function data_wp_text_processor( WP_Interactivity_API_Directives_Process
14141489 }
14151490 }
14161491
1492+ /**
1493+ * Processes the `data-wp-html` directive.
1494+ *
1495+ * It sets the content of the current HTML element to the HTML of the token
1496+ * that its associated reference resolves to. The HTML is written as given and
1497+ * the directives inside it are not processed. See
1498+ * {@see wp_interactivity_as_dangerous_html()} for the risks of marking HTML
1499+ * as trusted.
1500+ *
1501+ * The element is left as it would be without the directive, and a notice
1502+ * reports the reason, when the reference does not resolve to a token, the
1503+ * element cannot hold content, or the element also carries `data-wp-text` or,
1504+ * for a `template`, `data-wp-each`. A reference that resolves to `null`
1505+ * declines without a notice. Only the entry without a suffix or a unique ID
1506+ * is processed.
1507+ *
1508+ * @since 7.2.0
1509+ *
1510+ * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
1511+ * @param string $mode Whether the processing is entering or exiting the tag.
1512+ */
1513+ private function data_wp_html_processor ( WP_Interactivity_API_Directives_Processor $ p , string $ mode ): void {
1514+ if ( 'enter ' !== $ mode ) {
1515+ return ;
1516+ }
1517+
1518+ // Get the first valid `data-wp-html` entry without suffix or unique ID.
1519+ $ valid_entry = array_find (
1520+ $ this ->get_directive_entries ( $ p , 'html ' ),
1521+ fn ( $ entry ) => null === $ entry ['suffix ' ] && null === $ entry ['unique_id ' ] && ! empty ( $ entry ['value ' ] )
1522+ );
1523+
1524+ if ( null === $ valid_entry ) {
1525+ return ;
1526+ }
1527+
1528+ /*
1529+ * The structural reasons decline without evaluating the reference, so
1530+ * their notice does not depend on the value and no derived state runs
1531+ * for a result that would be discarded.
1532+ */
1533+ if ( ! $ p ->has_and_visits_its_closer_tag () ) {
1534+ _doing_it_wrong (
1535+ __METHOD__ ,
1536+ sprintf (
1537+ /* translators: %s: The HTML tag name of the element, in lowercase. */
1538+ __ ( 'The "data-wp-html" directive cannot set the content of a <%s> element because it cannot hold content. The element is left as it is. ' ),
1539+ esc_html ( strtolower ( $ p ->get_tag () ) )
1540+ ),
1541+ '7.2.0 '
1542+ );
1543+ return ;
1544+ }
1545+
1546+ $ combined_directive = null ;
1547+ if ( array_find ( $ this ->get_directive_entries ( $ p , 'text ' ), fn ( $ entry ) => null === $ entry ['suffix ' ] && null === $ entry ['unique_id ' ] ) ) {
1548+ $ combined_directive = 'data-wp-text ' ;
1549+ } elseif ( 'TEMPLATE ' === $ p ->get_tag () && ! empty ( $ this ->get_directive_entries ( $ p , 'each ' ) ) ) {
1550+ $ combined_directive = 'data-wp-each ' ;
1551+ }
1552+ if ( null !== $ combined_directive ) {
1553+ _doing_it_wrong (
1554+ __METHOD__ ,
1555+ sprintf (
1556+ /* translators: %s: The directive combined with data-wp-html, such as data-wp-text. */
1557+ __ ( 'The "data-wp-html" directive cannot be combined with the "%s" directive on the same element. The "data-wp-html" directive is ignored. ' ),
1558+ esc_html ( $ combined_directive )
1559+ ),
1560+ '7.2.0 '
1561+ );
1562+ return ;
1563+ }
1564+
1565+ /*
1566+ * An entry value that is not a string is a JSON literal written in the
1567+ * markup, which no store property can be named by, so it is not evaluated.
1568+ */
1569+ $ result = is_string ( $ valid_entry ['value ' ] ) ? $ this ->evaluate ( $ valid_entry ) : $ valid_entry ['value ' ];
1570+
1571+ // Unresolved references and `null` values keep the element as it is, as they do in the client.
1572+ if ( null === $ result ) {
1573+ return ;
1574+ }
1575+
1576+ $ html = WP_Interactivity_Dangerous_HTML::get_html ( $ result );
1577+ if ( null === $ html ) {
1578+ _doing_it_wrong (
1579+ __METHOD__ ,
1580+ __ ( 'The "data-wp-html" directive requires a reference that resolves to the value returned by wp_interactivity_as_dangerous_html(). Other values, such as strings of HTML, are not rendered and the element is left as it is. ' ),
1581+ '7.2.0 '
1582+ );
1583+ return ;
1584+ }
1585+
1586+ /*
1587+ * The HTML is held back and a placeholder comment takes its place, so no
1588+ * later step of the call scans it. A comment is a single token for the
1589+ * processors, and the marker in it occurs nowhere in the input.
1590+ */
1591+ $ placeholder = '<!-- ' . $ this ->html_placeholder_marker . ': ' . count ( $ this ->html_placeholders ) . '--> ' ;
1592+ if ( $ p ->set_raw_content_between_balanced_tags ( $ placeholder ) ) {
1593+ $ this ->html_placeholders [ $ placeholder ] = $ html ;
1594+ }
1595+ }
1596+
14171597 /**
14181598 * Returns the CSS styles for animating the top loading bar in the router.
14191599 *
0 commit comments