<?php
/**
* Interactivity API: WP_Interactivity_API class.
*
* @package WordPress
* @subpackage Interactivity API
* @since 6.5.0
*/
/**
* Class used to process the Interactivity API on the server.
*
* @since 6.5.0
*/
final class WP_Interactivity_API {
/**
* Holds the mapping of directive attribute names to their processor methods.
*
* @since 6.5.0
* @var array
*/
private static $directive_processors = array(
'data-wp-interactive' => 'data_wp_interactive_processor',
'data-wp-router-region' => 'data_wp_router_region_processor',
'data-wp-context' => 'data_wp_context_processor',
'data-wp-bind' => 'data_wp_bind_processor',
'data-wp-class' => 'data_wp_class_processor',
'data-wp-style' => 'data_wp_style_processor',
'data-wp-text' => 'data_wp_text_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
* processed twice.
*/
'data-wp-each' => 'data_wp_each_processor',
);
/**
* Holds the initial state of the different Interactivity API stores.
*
* This state is used during the server directive processing. Then, it is
* serialized and sent to the client as part of the interactivity data to be
* recovered during the hydration of the client interactivity stores.
*
* @since 6.5.0
* @var array
*/
private $state_data = array();
/**
* Holds the configuration required by the different Interactivity API stores.
*
* This configuration is serialized and sent to the client as part of the
* interactivity data and can be accessed by the client interactivity stores.
*
* @since 6.5.0
* @var array
*/
private $config_data = array();
/**
* Flag that indicates whether the `data-wp-router-region` directive has
* been found in the HTML and processed.
*
* The value is saved in a private property of the WP_Interactivity_API
* instance instead of using a static variable inside the processor
* function, which would hold the same value for all instances
* independently of whether they have processed any
* `data-wp-router-region` directive or not.
*
* @since 6.5.0
* @var bool
*/
private $has_processed_router_region = false;
/**
* Stack of namespaces defined by `data-wp-interactive` directives, in
* the order they are processed.
*
* This is only available during directive processing, otherwise it is `null`.
*
* @since 6.6.0
* @var array<string>|null
*/
private $namespace_stack = null;
/**
* Stack of contexts defined by `data-wp-context` directives, in
* the order they are processed.
*
* This is only available during directive processing, otherwise it is `null`.
*
* @since 6.6.0
* @var array<array<mixed>>|null
*/
private $context_stack = null;
/**
* Representation in array format of the element currently being processed.
*
* This is only available during directive processing, otherwise it is `null`.
*
* @since 6.7.0
* @var array{attributes: array<string, string|bool>}|null
*/
private $current_element = null;
/**
* Gets and/or sets the initial state of an Interactivity API store for a
* given namespace.
*
* If state for that store namespace already exists, it merges the new
* provided state with the existing one.
*
* When no namespace is specified, it returns the state defined for the
* current value in the internal namespace stack during a `process_directives` call.
*
* @since 6.5.0
* @since 6.6.0 The `$store_namespace` param is optional.
*
* @param string $store_namespace Optional. The unique store namespace identifier.
* @param array $state Optional. The array that will be merged with the existing state for the specified
* store namespace.
* @return array The current state for the specified store namespace. This will be the updated state if a $state
* argument was provided.
*/
public function state( ?string $store_namespace = null, ?array $state = null ): array {
if ( ! $store_namespace ) {
if ( $state ) {
_doing_it_wrong(
__METHOD__,
__( 'The namespace is required when state data is passed.' ),
'6.6.0'
);
return array();
}
if ( null !== $store_namespace ) {
_doing_it_wrong(
__METHOD__,
__( 'The namespace should be a non-empty string.' ),
'6.6.0'
);
return array();
}
if ( null === $this->namespace_stack ) {
_doing_it_wrong(
__METHOD__,
__( 'The namespace can only be omitted during directive processing.' ),
'6.6.0'
);
return array();
}
$store_namespace = end( $this->namespace_stack );
}
if ( ! isset( $this->state_data[ $store_namespace ] ) ) {
$this->state_data[ $store_namespace ] = array();
}
if ( is_array( $state ) ) {
$this->state_data[ $store_namespace ] = array_replace_recursive(
$this->state_data[ $store_namespace ],
$state
);
}
return $this->state_data[ $store_namespace ];
}
/**
* Gets and/or sets the configuration of the Interactivity API for a given
* store namespace.
*
* If configuration for that store namespace exists, it merges the new
* provided configuration with the existing one.
*
* @since 6.5.0
*
* @param string $store_namespace The unique store namespace identifier.
* @param array $config Optional. The array that will be merged with the existing configuration for the
* specified store namespace.
* @return array The configuration for the specified store namespace. This will be the updated configuration if a
* $config argument was provided.
*/
public function config( string $store_namespace, array $config = array() ): array {
if ( ! isset( $this->config_data[ $store_namespace ] ) ) {
$this->config_data[ $store_namespace ] = array();
}
if ( is_array( $config ) ) {
$this->config_data[ $store_namespace ] = array_replace_recursive(
$this->config_data[ $store_namespace ],
$config
);
}
return $this->config_data[ $store_namespace ];
}
/**
* Prints the serialized client-side interactivity data.
*
* Encodes the config and initial state into JSON and prints them inside a
* script tag of type "application/json". Once in the browser, the state will
* be parsed and used to hydrate the client-side interactivity stores and the
* configuration will be available using a `getConfig` utility.
*
* @since 6.5.0
*
* @deprecated 6.7.0 Client data passing is handled by the {@see "script_module_data_{$module_id}"} filter.
*/
public function print_client_interactivity_data() {
_deprecated_function( __METHOD__, '6.7.0' );
}
/**
* Set client-side interactivity-router data.
*
* Once in the browser, the state will be parsed and used to hydrate the client-side
* interactivity stores and the configuration will be available using a `getConfig` utility.
*
* @since 6.7.0
*
* @param array $data Data to filter.
* @return array Data for the Interactivity Router script module.
*/
public function filter_script_module_interactivity_router_data( array $data ): array {
if ( ! isset( $data['i18n'] ) ) {
$data['i18n'] = array();
}
$data['i18n']['loading'] = __( 'Loading page, please wait.' );
$data['i18n']['loaded'] = __( 'Page Loaded.' );
return $data;
}
/**
* Set client-side interactivity data.
*
* Once in the browser, the state will be parsed and used to hydrate the client-side
* interactivity stores and the configuration will be available using a `getConfig` utility.
*
* @since 6.7.0
*
* @param array $data Data to filter.
* @return array Data for the Interactivity API script module.
*/
public function filter_script_module_interactivity_data( array $data ): array {
if ( empty( $this->state_data ) && empty( $this->config_data ) ) {
return $data;
}
$config = array();
foreach ( $this->config_data as $key => $value ) {
if ( ! empty( $value ) ) {
$config[ $key ] = $value;
}
}
if ( ! empty( $config ) ) {
$data['config'] = $config;
}
$state = array();
foreach ( $this->state_data as $key => $value ) {
if ( ! empty( $value ) ) {
$state[ $key ] = $value;
}
}
if ( ! empty( $state ) ) {
$data['state'] = $state;
}
return $data;
}
/**
* Returns the latest value on the context stack with the passed namespace.
*
* When the namespace is omitted, it uses the current namespace on the
* namespace stack during a `process_directives` call.
*
* @since 6.6.0
*
* @param string $store_namespace Optional. The unique store namespace identifier.
*/
public function get_context( ?string $store_namespace = null ): array {
if ( null === $this->context_stack ) {
_doing_it_wrong(
__METHOD__,
__( 'The context can only be read during directive processing.' ),
'6.6.0'
);
return array();
}
if ( ! $store_namespace ) {
if ( null !== $store_namespace ) {
_doing_it_wrong(
__METHOD__,
__( 'The namespace should be a non-empty string.' ),
'6.6.0'
);
return array();
}
$store_namespace = end( $this->namespace_stack );
}
$context = end( $this->context_stack );
return ( $store_namespace && $context && isset( $context[ $store_namespace ] ) )
? $context[ $store_namespace ]
: array();
}
/**
* Returns an array representation of the current element being processed.
*
* The returned array contains a copy of the element attributes.
*
* @since 6.7.0
*
* @return array{attributes: array<string, string|bool>}|null Current element.
*/
public function get_element(): ?array {
if ( null === $this->current_element ) {
_doing_it_wrong(
__METHOD__,
__( 'The element can only be read during directive processing.' ),
'6.7.0'
);
}
return $this->current_element;
}
/**
* Registers the `@wordpress/interactivity` script modules.
*
* @deprecated 6.7.0 Script Modules registration is handled by {@see wp_default_script_modules()}.
*
* @since 6.5.0
*/
public function register_script_modules() {
_deprecated_function( __METHOD__, '6.7.0', 'wp_default_script_modules' );
}
/**
* Adds the necessary hooks for the Interactivity API.
*
* @since 6.5.0
*/
public function add_hooks() {
add_filter( 'script_module_data_@wordpress/interactivity', array( $this, 'filter_script_module_interactivity_data' ) );
add_filter( 'script_module_data_@wordpress/interactivity-router', array( $this, 'filter_script_module_interactivity_router_data' ) );
}
/**
* Processes the interactivity directives contained within the HTML content
* and updates the markup accordingly.
*
* @since 6.5.0
*
* @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.
*/
public function process_directives( string $html ): string {
if ( ! str_contains( $html, 'data-wp-' ) ) {
return $html;
}
$this->namespace_stack = array();
$this->context_stack = array();
$result = $this->_process_directives( $html );
$this->namespace_stack = null;
$this->context_stack = null;
return null === $result ? $html : $result;
}
/**
* Processes the interactivity directives contained within the HTML content
* and updates the markup accordingly.
*
* It uses the WP_Interactivity_API instance's context and namespace stacks,
* which are shared between all calls.
*
* This method returns null if the HTML contains unbalanced tags.
*
* @since 6.6.0
*
* @param string $html The HTML content to process.
* @return string|null The processed HTML content. It returns null when the HTML contains unbalanced tags.
*/
private function _process_directives( string $html ) {
$p = new WP_Interactivity_API_Directives_Processor( $html );
$tag_stack = array();
$unbalanced = false;
$directive_processor_prefixes = array_keys( self::$directive_processors );
$directive_processor_prefixes_reversed = array_reverse( $directive_processor_prefixes );
/*
* Save the current size for each stack to restore them in case
* the processing finds unbalanced tags.
*/
$namespace_stack_size = count( $this->namespace_stack );
$context_stack_size = count( $this->context_stack );
while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) {
$tag_name = $p->get_tag();
/*
* Directives inside SVG and MATH tags are not processed,
* as they are not compatible with the Tag Processor yet.
* We still process the rest of the HTML.
*/
if ( 'SVG' === $tag_name || 'MATH' === $tag_name ) {
if ( $p->get_attribute_names_with_prefix( 'data-wp-' ) ) {
/* translators: 1: SVG or MATH HTML tag, 2: Namespace of the interactive block. */
$message = sprintf( __( 'Interactivity directives were detected on an incompatible %1$s tag when processing "%2$s". These directives will be ignored in the server side render.' ), $tag_name, end( $this->namespace_stack ) );
_doing_it_wrong( __METHOD__, $message, '6.6.0' );
}
$p->skip_to_tag_closer();
continue;
}
if ( $p->is_tag_closer() ) {
list( $opening_tag_name, $directives_prefixes ) = end( $tag_stack );
if ( 0 === count( $tag_stack ) || $opening_tag_name !== $tag_name ) {
/*
* If the tag stack is empty or the matching opening tag is not the
* same than the closing tag, it means the HTML is unbalanced and it
* stops processing it.
*/
$unbalanced = true;
break;
} else {
// Remove the last tag from the stack.
array_pop( $tag_stack );
}
} else {
if ( 0 !== count( $p->get_attribute_names_with_prefix( 'data-wp-each-child' ) ) ) {
/*
* If the tag has a `data-wp-each-child` directive, jump to its closer
* tag because those tags have already been processed.
*/
$p->next_balanced_tag_closer_tag();
continue;
} else {
$directives_prefixes = array();
// Checks if there is a server directive processor registered for each directive.
foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) {
if ( ! preg_match(
/*
* This must align with the client-side regex used by the interactivity API.
* @see https://github.com/WordPress/gutenberg/blob/ca616014255efbb61f34c10917d52a2d86c1c660/packages/interactivity/src/vdom.ts#L20-L32
*/
'/' .
'^data-wp-' .
// Match alphanumeric characters including hyphen-separated
// segments. It excludes underscore intentionally to prevent confusion.
// E.g., "custom-directive".
'([a-z0-9]+(?:-[a-z0-9]+)*)' .
// (Optional) Match '--' followed by any alphanumeric charachters. It
// excludes underscore intentionally to prevent confusion, but it can
// contain multiple hyphens. E.g., "--custom-prefix--with-more-info".
'(?:--([a-z0-9_-]+))?$' .
'/i',
$attribute_name
) ) {
continue;
}
list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name );
if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) {
$directives_prefixes[] = $directive_prefix;
}
}
/*
* If this tag will visit its closer tag, it adds it to the tag stack
* so it can process its closing tag and check for unbalanced tags.
*/
if ( $p->has_and_visits_its_closer_tag() ) {
$tag_stack[] = array( $tag_name, $directives_prefixes );
}
}
}
/*
* If the matching opener tag didn't have any directives, it can skip the
* processing.
*/
if ( 0 === count( $directives_prefixes ) ) {
continue;
}
// Directive processing might be different depending on if it is entering the tag or exiting it.
$modes = array(
'enter' => ! $p->is_tag_closer(),
'exit' => $p->is_tag_closer() || ! $p->has_and_visits_its_closer_tag(),
);
// Get the element attributes to include them in the element representation.
$element_attrs = array();
$attr_names = $p->get_attribute_names_with_prefix( '' ) ?? array();
foreach ( $attr_names as $name ) {
$element_attrs[ $name