Commit 25db359217 for wordpress.org
commit 25db359217a959b419957f6897cfb3780d3e6eb7
Author: luisherranz <luisherranz@git.wordpress.org>
Date: Fri Oct 16 05:09:48 2026 +0000
Interactivity API: Add server-side processing for the `data-wp-html` directive.
Adds server-side processing for the `data-wp-html` directive, whose client implementation was added in Gutenberg, so that trusted HTML produced in PHP is present in the server-rendered page.
The new `wp_interactivity_as_dangerous_html()` function returns a `WP_Interactivity_Dangerous_HTML` token that marks an HTML string as trusted. Only these tokens render. The HTML is neither sanitized nor processed for directives, and it is never included in the data printed for the client, where a token is encoded as `{}`. An unserialized token holds no HTML and does not render.
When the directive cannot render, the element keeps its existing content and a `_doing_it_wrong()` notice is raised. That happens when the value is not a token, when the element cannot hold content, or when the element also uses `data-wp-text` or `data-wp-each` on a `template`.
Also adds `WP_Interactivity_API_Directives_Processor::set_raw_content_between_balanced_tags()`, which replaces the content between balanced tags without escaping it.
Props darerodz.
Fixes #66246.
Built from https://develop.svn.wordpress.org/trunk@64386
git-svn-id: http://core.svn.wordpress.org/trunk@63536 1a063a9b-81f0-0310-95a4-ce76da25c4cd
diff --git a/wp-includes/interactivity-api/class-wp-interactivity-api-directives-processor.php b/wp-includes/interactivity-api/class-wp-interactivity-api-directives-processor.php
index 999892758b..bac24fba19 100644
--- a/wp-includes/interactivity-api/class-wp-interactivity-api-directives-processor.php
+++ b/wp-includes/interactivity-api/class-wp-interactivity-api-directives-processor.php
@@ -71,6 +71,25 @@ final class WP_Interactivity_API_Directives_Processor extends WP_HTML_Tag_Proces
* @return bool Whether the content was successfully replaced.
*/
public function set_content_between_balanced_tags( string $new_content ): bool {
+ return $this->set_raw_content_between_balanced_tags( esc_html( $new_content ) );
+ }
+
+ /**
+ * Sets the content between two balanced tags, writing it as given.
+ *
+ * Unlike {@see WP_Interactivity_API_Directives_Processor::set_content_between_balanced_tags()},
+ * it does not escape the content. Like it, it replaces the content between
+ * the current opener tag and its matching closer tag, and it rewinds to the
+ * opener tag.
+ *
+ * @since 7.2.0
+ *
+ * @access private
+ *
+ * @param string $new_content The markup to replace the content between the matching tags. It is written as is.
+ * @return bool Whether the content was successfully replaced.
+ */
+ public function set_raw_content_between_balanced_tags( string $new_content ): bool {
$positions = $this->get_after_opener_tag_and_before_closer_tag_positions( true );
if ( ! $positions ) {
return false;
@@ -80,7 +99,7 @@ final class WP_Interactivity_API_Directives_Processor extends WP_HTML_Tag_Proces
$this->lexical_updates[] = new WP_HTML_Text_Replacement(
$after_opener_tag,
$before_closer_tag - $after_opener_tag,
- esc_html( $new_content )
+ $new_content
);
return true;
diff --git a/wp-includes/interactivity-api/class-wp-interactivity-api.php b/wp-includes/interactivity-api/class-wp-interactivity-api.php
index 8a2bc34675..6ec333401b 100644
--- a/wp-includes/interactivity-api/class-wp-interactivity-api.php
+++ b/wp-includes/interactivity-api/class-wp-interactivity-api.php
@@ -26,6 +26,7 @@ final class WP_Interactivity_API {
* 'data-wp-class': 'data_wp_class_processor',
* 'data-wp-style': 'data_wp_style_processor',
* 'data-wp-text': 'data_wp_text_processor',
+ * 'data-wp-html': 'data_wp_html_processor',
* 'data-wp-each': 'data_wp_each_processor',
* }
*/
@@ -37,6 +38,12 @@ final class WP_Interactivity_API {
'data-wp-class' => 'data_wp_class_processor',
'data-wp-style' => 'data_wp_style_processor',
'data-wp-text' => 'data_wp_text_processor',
+ /*
+ * `data-wp-html` is processed after `data-wp-text`, which it cannot be
+ * combined with, and before `data-wp-each`, so that it resolves its
+ * reference in the namespace and context its own element declares.
+ */
+ 'data-wp-html' => 'data_wp_html_processor',
/*
* `data-wp-each` needs to be processed in the last place because it moves
* the cursor to the end of the processed items to prevent them to be
@@ -144,6 +151,25 @@ final class WP_Interactivity_API {
*/
private $current_element = null;
+ /**
+ * Marker that makes the placeholders of the current `process_directives()`
+ * call recognizable. It occurs nowhere in the HTML that call received.
+ *
+ * @since 7.2.0
+ * @var string
+ */
+ private $html_placeholder_marker = '';
+
+ /**
+ * Trusted HTML written by `data-wp-html` during the current
+ * `process_directives()` call, indexed by the placeholder comment standing
+ * in for it until the call has finished.
+ *
+ * @since 7.2.0
+ * @var array<string, string>
+ */
+ private $html_placeholders = array();
+
/**
* Gets and/or sets the initial state of an Interactivity API store for a
* given namespace.
@@ -455,6 +481,8 @@ final class WP_Interactivity_API {
* and updates the markup accordingly.
*
* @since 6.5.0
+ * @since 7.2.0 The output includes the trusted HTML that `data-wp-html` renders. That HTML is
+ * added once the whole call has finished, so it is never processed.
*
* @param string $html The HTML content to process.
* @return string The processed HTML content. It returns the original content when the HTML contains unbalanced tags.
@@ -467,12 +495,34 @@ final class WP_Interactivity_API {
$this->namespace_stack = array();
$this->context_stack = array();
+ /*
+ * The trusted HTML of `data-wp-html` hosts never takes part in the
+ * processing: each host receives a placeholder comment, and the comments
+ * are replaced by their HTML once the result is known. The marker that
+ * identifies them is chosen so that it occurs nowhere in the input, which
+ * makes it impossible for the input to contain a placeholder.
+ */
+ $marker = 'wp-interactivity-dangerous-html';
+ while ( str_contains( $html, $marker ) ) {
+ $marker .= '_';
+ }
+ $this->html_placeholder_marker = $marker;
+ $this->html_placeholders = array();
+
$result = $this->_process_directives( $html );
$this->namespace_stack = null;
$this->context_stack = null;
- return $result ?? $html;
+ $placeholders = $this->html_placeholders;
+ $this->html_placeholders = array();
+
+ if ( null === $result ) {
+ return $html;
+ }
+
+ // `strtr()` never searches the replacements it has inserted.
+ return strtr( $result, $placeholders );
}
/**
@@ -656,6 +706,7 @@ final class WP_Interactivity_API {
* @since 6.6.0 Removed `default_namespace` and `context` arguments.
* @since 6.6.0 Add support for derived state.
* @since 6.9.0 Receive $entry as an argument instead of the directive value string.
+ * @since 7.2.0 Tracks the path of a token that marks HTML as trusted, reached through state.
*
* @param array $entry An array containing a whole directive entry with its namespace, value, suffix, or unique ID.
* @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 @@ final class WP_Interactivity_API {
$path = $should_negate_value ? substr( $path, 1 ) : $path;
// Extracts the value from the store using the reference path.
- $path_segments = explode( '.', $path );
- $current = $store;
+ $path_segments = explode( '.', $path );
+ $current = $store;
+ $called_closure = false;
foreach ( $path_segments as $index => $path_segment ) {
/*
* Special case for numeric arrays and strings. Add length
@@ -731,7 +783,8 @@ final class WP_Interactivity_API {
*/
array_push( $this->namespace_stack, $ns );
try {
- $current = $current();
+ $called_closure = true;
+ $current = $current();
/*
* Tracks derived state properties that are accessed during
@@ -765,6 +818,28 @@ final class WP_Interactivity_API {
}
}
+ /*
+ * Tracks the tokens that mark HTML as trusted and that a reference
+ * reaches through state, the way derived state closures are tracked, so
+ * the client keeps the server-rendered content of a token until its
+ * store supplies a value. A token reached through context, or through
+ * the result of a derived state closure, is not tracked: the client has
+ * no context store to resolve it from, and the closure is tracked
+ * instead.
+ *
+ * @since 7.2.0
+ */
+ if (
+ 'state' === $path_segments[0] &&
+ ! $called_closure &&
+ $current instanceof WP_Interactivity_Dangerous_HTML
+ ) {
+ $this->derived_state_closures[ $ns ] ??= array();
+ if ( ! in_array( $path, $this->derived_state_closures[ $ns ], true ) ) {
+ $this->derived_state_closures[ $ns ][] = $path;
+ }
+ }
+
// Returns the opposite if it contains a negation operator (!).
return $should_negate_value ? ! $current : $current;
}
@@ -1414,6 +1489,111 @@ final class WP_Interactivity_API {
}
}
+ /**
+ * Processes the `data-wp-html` directive.
+ *
+ * It sets the content of the current HTML element to the HTML of the token
+ * that its associated reference resolves to. The HTML is written as given and
+ * the directives inside it are not processed. See
+ * {@see wp_interactivity_as_dangerous_html()} for the risks of marking HTML
+ * as trusted.
+ *
+ * The element is left as it would be without the directive, and a notice
+ * reports the reason, when the reference does not resolve to a token, the
+ * element cannot hold content, or the element also carries `data-wp-text` or,
+ * for a `template`, `data-wp-each`. A reference that resolves to `null`
+ * declines without a notice. Only the entry without a suffix or a unique ID
+ * is processed.
+ *
+ * @since 7.2.0
+ *
+ * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
+ * @param string $mode Whether the processing is entering or exiting the tag.
+ */
+ private function data_wp_html_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ): void {
+ if ( 'enter' !== $mode ) {
+ return;
+ }
+
+ // Get the first valid `data-wp-html` entry without suffix or unique ID.
+ $valid_entry = array_find(
+ $this->get_directive_entries( $p, 'html' ),
+ fn( $entry ) => null === $entry['suffix'] && null === $entry['unique_id'] && ! empty( $entry['value'] )
+ );
+
+ if ( null === $valid_entry ) {
+ return;
+ }
+
+ /*
+ * The structural reasons decline without evaluating the reference, so
+ * their notice does not depend on the value and no derived state runs
+ * for a result that would be discarded.
+ */
+ if ( ! $p->has_and_visits_its_closer_tag() ) {
+ _doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: %s: The HTML tag name of the element, in lowercase. */
+ __( '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.' ),
+ esc_html( strtolower( $p->get_tag() ) )
+ ),
+ '7.2.0'
+ );
+ return;
+ }
+
+ $combined_directive = null;
+ if ( array_find( $this->get_directive_entries( $p, 'text' ), fn( $entry ) => null === $entry['suffix'] && null === $entry['unique_id'] ) ) {
+ $combined_directive = 'data-wp-text';
+ } elseif ( 'TEMPLATE' === $p->get_tag() && ! empty( $this->get_directive_entries( $p, 'each' ) ) ) {
+ $combined_directive = 'data-wp-each';
+ }
+ if ( null !== $combined_directive ) {
+ _doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: %s: The directive combined with data-wp-html, such as data-wp-text. */
+ __( 'The "data-wp-html" directive cannot be combined with the "%s" directive on the same element. The "data-wp-html" directive is ignored.' ),
+ esc_html( $combined_directive )
+ ),
+ '7.2.0'
+ );
+ return;
+ }
+
+ /*
+ * An entry value that is not a string is a JSON literal written in the
+ * markup, which no store property can be named by, so it is not evaluated.
+ */
+ $result = is_string( $valid_entry['value'] ) ? $this->evaluate( $valid_entry ) : $valid_entry['value'];
+
+ // Unresolved references and `null` values keep the element as it is, as they do in the client.
+ if ( null === $result ) {
+ return;
+ }
+
+ $html = WP_Interactivity_Dangerous_HTML::get_html( $result );
+ if ( null === $html ) {
+ _doing_it_wrong(
+ __METHOD__,
+ __( '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.' ),
+ '7.2.0'
+ );
+ return;
+ }
+
+ /*
+ * The HTML is held back and a placeholder comment takes its place, so no
+ * later step of the call scans it. A comment is a single token for the
+ * processors, and the marker in it occurs nowhere in the input.
+ */
+ $placeholder = '<!--' . $this->html_placeholder_marker . ':' . count( $this->html_placeholders ) . '-->';
+ if ( $p->set_raw_content_between_balanced_tags( $placeholder ) ) {
+ $this->html_placeholders[ $placeholder ] = $html;
+ }
+ }
+
/**
* Returns the CSS styles for animating the top loading bar in the router.
*
diff --git a/wp-includes/interactivity-api/class-wp-interactivity-dangerous-html.php b/wp-includes/interactivity-api/class-wp-interactivity-dangerous-html.php
new file mode 100644
index 0000000000..bcfc8334d7
--- /dev/null
+++ b/wp-includes/interactivity-api/class-wp-interactivity-dangerous-html.php
@@ -0,0 +1,76 @@
+<?php
+/**
+ * Interactivity API: WP_Interactivity_Dangerous_HTML class.
+ *
+ * @package WordPress
+ * @subpackage Interactivity API
+ * @since 7.2.0
+ */
+
+/**
+ * Token that marks an HTML string as trusted, for server directive processing
+ * to render as the content of an element.
+ *
+ * Create tokens with {@see wp_interactivity_as_dangerous_html()}, which
+ * documents the risks of marking HTML as trusted.
+ *
+ * The HTML is kept in a private property, so encoding a token as JSON never
+ * includes it. A token restored by unserialization is inert: it holds no HTML,
+ * so it never renders.
+ *
+ * @since 7.2.0
+ *
+ * @access private
+ */
+final class WP_Interactivity_Dangerous_HTML {
+ /**
+ * The trusted HTML. It is `null` for a token restored by unserialization,
+ * which never renders.
+ *
+ * @since 7.2.0
+ * @var string|null
+ */
+ private $html = null;
+
+ /**
+ * Constructor.
+ *
+ * @since 7.2.0
+ *
+ * @param string $html The HTML to mark as trusted. It is not sanitized.
+ */
+ public function __construct( string $html ) {
+ $this->html = $html;
+ }
+
+ /**
+ * Gets the trusted HTML of a value.
+ *
+ * @since 7.2.0
+ *
+ * @param mixed $value Any value.
+ * @return string|null The HTML of the token if the value is a token whose constructor
+ * ran, or `null` for any other value.
+ */
+ public static function get_html( $value ): ?string {
+ return $value instanceof self ? $value->html : null;
+ }
+
+ /**
+ * Makes a token restored by unserialization inert.
+ *
+ * Clears the HTML, so the token never renders, and reports the unserialization
+ * instead of throwing, so a page that cached a token keeps working.
+ *
+ * @since 7.2.0
+ */
+ public function __wakeup(): void {
+ $this->html = null;
+
+ _doing_it_wrong(
+ __METHOD__,
+ __( 'A token that marks HTML as trusted should not be unserialized. The unserialized token holds no HTML and does not render.' ),
+ '7.2.0'
+ );
+ }
+}
diff --git a/wp-includes/interactivity-api/interactivity-api.php b/wp-includes/interactivity-api/interactivity-api.php
index 105f3f6f29..81c6362b94 100644
--- a/wp-includes/interactivity-api/interactivity-api.php
+++ b/wp-includes/interactivity-api/interactivity-api.php
@@ -138,3 +138,26 @@ function wp_interactivity_get_context( ?string $store_namespace = null ): array
function wp_interactivity_get_element(): ?array {
return wp_interactivity()->get_element();
}
+
+/**
+ * Marks an HTML string as trusted, for `data-wp-html` to render as the content of an element.
+ *
+ * The returned token is the only value `data-wp-html` renders in server directive processing.
+ *
+ * Warning: this function does not sanitize the HTML. Passing untrusted input, such as
+ * user-submitted content, creates a cross-site scripting (XSS) vulnerability. Sanitize it
+ * first, for example with `wp_kses_post()`, or build it only from trusted markup.
+ *
+ * A value read from context must never become a token, directly or after being copied into
+ * state. Context selects and state supplies: context is controlled by the client or by markup,
+ * so it may only select among tokens that server-side code created, and must never be the
+ * HTML a token is created from.
+ *
+ * @since 7.2.0
+ *
+ * @param string $html The HTML to mark as trusted. It is not sanitized.
+ * @return WP_Interactivity_Dangerous_HTML The token that carries the HTML.
+ */
+function wp_interactivity_as_dangerous_html( string $html ): WP_Interactivity_Dangerous_HTML {
+ return new WP_Interactivity_Dangerous_HTML( $html );
+}
diff --git a/wp-includes/version.php b/wp-includes/version.php
index 236bd0d55b..f546c60ee7 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
*
* @global string $wp_version
*/
-$wp_version = '7.2-alpha-64385';
+$wp_version = '7.2-alpha-64386';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.
diff --git a/wp-settings.php b/wp-settings.php
index c875b79161..0340582015 100644
--- a/wp-settings.php
+++ b/wp-settings.php
@@ -466,6 +466,7 @@ require ABSPATH . WPINC . '/class-wp-script-modules.php';
require ABSPATH . WPINC . '/script-modules.php';
require ABSPATH . WPINC . '/interactivity-api/class-wp-interactivity-api.php';
require ABSPATH . WPINC . '/interactivity-api/class-wp-interactivity-api-directives-processor.php';
+require ABSPATH . WPINC . '/interactivity-api/class-wp-interactivity-dangerous-html.php';
require ABSPATH . WPINC . '/interactivity-api/interactivity-api.php';
require ABSPATH . WPINC . '/class-wp-plugin-dependencies.php';
require ABSPATH . WPINC . '/class-wp-url-pattern-prefixer.php';