[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

/wp-includes/interactivity-api/ -> class-wp-interactivity-api.php (source)

   1  <?php
   2  /**
   3   * Interactivity API: WP_Interactivity_API class.
   4   *
   5   * @package WordPress
   6   * @subpackage Interactivity API
   7   * @since 6.5.0
   8   */
   9  
  10  /**
  11   * Class used to process the Interactivity API on the server.
  12   *
  13   * @since 6.5.0
  14   */
  15  final class WP_Interactivity_API {
  16      /**
  17       * Holds the mapping of directive attribute names to their processor methods.
  18       *
  19       * @since 6.5.0
  20       * @var array<string, string>
  21       * @phpstan-var array{
  22       *     'data-wp-interactive': 'data_wp_interactive_processor',
  23       *     'data-wp-router-region': 'data_wp_router_region_processor',
  24       *     'data-wp-context': 'data_wp_context_processor',
  25       *     'data-wp-bind': 'data_wp_bind_processor',
  26       *     'data-wp-class': 'data_wp_class_processor',
  27       *     'data-wp-style': 'data_wp_style_processor',
  28       *     'data-wp-text': 'data_wp_text_processor',
  29       *     'data-wp-html': 'data_wp_html_processor',
  30       *     'data-wp-each': 'data_wp_each_processor',
  31       * }
  32       */
  33      private static array $directive_processors = array(
  34          'data-wp-interactive'   => 'data_wp_interactive_processor',
  35          'data-wp-router-region' => 'data_wp_router_region_processor',
  36          'data-wp-context'       => 'data_wp_context_processor',
  37          'data-wp-bind'          => 'data_wp_bind_processor',
  38          'data-wp-class'         => 'data_wp_class_processor',
  39          'data-wp-style'         => 'data_wp_style_processor',
  40          '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',
  47          /*
  48           * `data-wp-each` needs to be processed in the last place because it moves
  49           * the cursor to the end of the processed items to prevent them to be
  50           * processed twice.
  51           */
  52          'data-wp-each'          => 'data_wp_each_processor',
  53      );
  54  
  55      /**
  56       * Matches a directive value that names its namespace, as in
  57       * `myPlugin::state.foo`, capturing the namespace and the rest of the value.
  58       *
  59       * @since 7.2.0
  60       * @var string
  61       */
  62      private const NAMESPACED_VALUE_REGEX = '/^([\w\/-]+)::(.+)$/D';
  63  
  64      /**
  65       * Holds the initial state of the different Interactivity API stores.
  66       *
  67       * This state is used during the server directive processing. Then, it is
  68       * serialized and sent to the client as part of the interactivity data to be
  69       * recovered during the hydration of the client interactivity stores.
  70       *
  71       * @since 6.5.0
  72       * @var array
  73       */
  74      private $state_data = array();
  75  
  76      /**
  77       * Holds the configuration required by the different Interactivity API stores.
  78       *
  79       * This configuration is serialized and sent to the client as part of the
  80       * interactivity data and can be accessed by the client interactivity stores.
  81       *
  82       * @since 6.5.0
  83       * @var array
  84       */
  85      private $config_data = array();
  86  
  87      /**
  88       * Keeps track of all derived state closures accessed during server-side rendering.
  89       *
  90       * This data is serialized and sent to the client as part of the interactivity
  91       * data, and is handled later in the client to support derived state props that
  92       * are lazily hydrated.
  93       *
  94       * @since 6.9.0
  95       * @var array
  96       */
  97      private $derived_state_closures = array();
  98  
  99      /**
 100       * Flag that indicates whether the `data-wp-router-region` directive has
 101       * been found in the HTML and processed.
 102       *
 103       * The value is saved in a private property of the WP_Interactivity_API
 104       * instance instead of using a static variable inside the processor
 105       * function, which would hold the same value for all instances
 106       * independently of whether they have processed any
 107       * `data-wp-router-region` directive or not.
 108       *
 109       * @since 6.5.0
 110       * @var bool
 111       */
 112      private $has_processed_router_region = false;
 113  
 114      /**
 115       * Set of script modules that can be loaded after client-side navigation.
 116       *
 117       * @since 6.9.0
 118       * @var array<string, true>
 119       */
 120      private $script_modules_that_can_load_on_client_navigation = array();
 121  
 122      /**
 123       * Stack of namespaces defined by `data-wp-interactive` directives, in
 124       * the order they are processed.
 125       *
 126       * This is only available during directive processing, otherwise it is `null`.
 127       *
 128       * An entry is the namespace the directive defined. It is `false` instead when
 129       * the directive did not define a usable one — the attribute was empty, or its
 130       * JSON held no `namespace`, or the namespace did not match the accepted
 131       * characters — and no enclosing `data-wp-interactive` was in effect to inherit
 132       * from. An entry is pushed either way, because one is popped for every closing
 133       * tag regardless of what the directive contained, so `false` is what stands in
 134       * for "no namespace here" and keeps the stack balanced.
 135       *
 136       * @since 6.6.0
 137       * @var array<string|false>|null
 138       * @phpstan-var list<string|false>|null
 139       */
 140      private $namespace_stack = null;
 141  
 142      /**
 143       * Stack of the namespaces that `data-wp-interactive` directives name, in
 144       * step with the namespace stack, as the client reads them.
 145       *
 146       * This is only available during directive processing, otherwise it is `null`.
 147       *
 148       * Unlike the namespace stack, the entry of an element that carries the
 149       * attribute named exactly `data-wp-interactive` never falls back to an
 150       * enclosing namespace. It is `null` when the attribute names none: it has no
 151       * value or an empty one, its JSON object holds no string `namespace`, or the
 152       * namespace is `__proto__`, `constructor` or `prototype`. An element with
 153       * only suffixed or unique-ID entries is no island, so its entry repeats the
 154       * enclosing one. Only `data-wp-html` reads it, as the client resolves every
 155       * directive.
 156       *
 157       * @since 7.2.0
 158       * @var array<string|null>|null
 159       * @phpstan-var list<string|null>|null
 160       */
 161      private $island_namespace_stack = null;
 162  
 163      /**
 164       * Stack of contexts defined by `data-wp-context` directives, in
 165       * the order they are processed.
 166       *
 167       * This is only available during directive processing, otherwise it is `null`.
 168       *
 169       * @since 6.6.0
 170       * @var array<array<mixed>>|null
 171       */
 172      private $context_stack = null;
 173  
 174      /**
 175       * Representation in array format of the element currently being processed.
 176       *
 177       * This is only available during directive processing, otherwise it is `null`.
 178       *
 179       * @since 6.7.0
 180       * @var array{attributes: array<string, string|bool>}|null
 181       */
 182      private $current_element = null;
 183  
 184      /**
 185       * Marker that makes the placeholders of the current `process_directives()`
 186       * call recognizable, or an empty string until the first `data-wp-html` host
 187       * of the call renders and draws it. It holds a random UUID, so no text
 188       * written before the call can realistically hold it.
 189       *
 190       * @since 7.2.0
 191       * @var string
 192       */
 193      private $html_placeholder_marker = '';
 194  
 195      /**
 196       * Trusted HTML written by `data-wp-html` during the current
 197       * `process_directives()` call, indexed by the placeholder comment standing
 198       * in for it until the call has finished.
 199       *
 200       * @since 7.2.0
 201       * @var array<string, string>
 202       */
 203      private $html_placeholders = array();
 204  
 205      /**
 206       * Gets and/or sets the initial state of an Interactivity API store for a
 207       * given namespace.
 208       *
 209       * If state for that store namespace already exists, it merges the new
 210       * provided state with the existing one.
 211       *
 212       * When no namespace is specified, it returns the state defined for the
 213       * current value in the internal namespace stack during a `process_directives` call.
 214       *
 215       * @since 6.5.0
 216       * @since 6.6.0 The `$store_namespace` param is optional.
 217       *
 218       * @param string|null $store_namespace Optional. The unique store namespace identifier.
 219       * @param array|null  $state           Optional. The array that will be merged with the existing state for the specified
 220       *                                store namespace.
 221       * @return array The current state for the specified store namespace. This will be the updated state if a $state
 222       *               argument was provided.
 223       */
 224  	public function state( ?string $store_namespace = null, ?array $state = null ): array {
 225          if ( ! $store_namespace ) {
 226              if ( $state ) {
 227                  _doing_it_wrong(
 228                      __METHOD__,
 229                      __( 'The namespace is required when state data is passed.' ),
 230                      '6.6.0'
 231                  );
 232                  return array();
 233              }
 234              if ( null !== $store_namespace ) {
 235                  _doing_it_wrong(
 236                      __METHOD__,
 237                      __( 'The namespace should be a non-empty string.' ),
 238                      '6.6.0'
 239                  );
 240                  return array();
 241              }
 242              if ( null === $this->namespace_stack ) {
 243                  _doing_it_wrong(
 244                      __METHOD__,
 245                      __( 'The namespace can only be omitted during directive processing.' ),
 246                      '6.6.0'
 247                  );
 248                  return array();
 249              }
 250  
 251              $store_namespace = end( $this->namespace_stack );
 252          }
 253          $this->state_data[ $store_namespace ] ??= array();
 254          if ( is_array( $state ) ) {
 255              $this->state_data[ $store_namespace ] = array_replace_recursive(
 256                  $this->state_data[ $store_namespace ],
 257                  $state
 258              );
 259          }
 260          return $this->state_data[ $store_namespace ];
 261      }
 262  
 263      /**
 264       * Gets and/or sets the configuration of the Interactivity API for a given
 265       * store namespace.
 266       *
 267       * If configuration for that store namespace exists, it merges the new
 268       * provided configuration with the existing one.
 269       *
 270       * @since 6.5.0
 271       *
 272       * @param string $store_namespace The unique store namespace identifier.
 273       * @param array  $config          Optional. The array that will be merged with the existing configuration for the
 274       *                                specified store namespace.
 275       * @return array The configuration for the specified store namespace. This will be the updated configuration if a
 276       *               $config argument was provided.
 277       */
 278  	public function config( string $store_namespace, array $config = array() ): array {
 279          $this->config_data[ $store_namespace ] ??= array();
 280          if ( is_array( $config ) ) {
 281              $this->config_data[ $store_namespace ] = array_replace_recursive(
 282                  $this->config_data[ $store_namespace ],
 283                  $config
 284              );
 285          }
 286          return $this->config_data[ $store_namespace ];
 287      }
 288  
 289      /**
 290       * Prints the serialized client-side interactivity data.
 291       *
 292       * Encodes the config and initial state into JSON and prints them inside a
 293       * script tag of type "application/json". Once in the browser, the state will
 294       * be parsed and used to hydrate the client-side interactivity stores and the
 295       * configuration will be available using a `getConfig` utility.
 296       *
 297       * @since 6.5.0
 298       *
 299       * @deprecated 6.7.0 Client data passing is handled by the {@see "script_module_data_{$module_id}"} filter.
 300       */
 301  	public function print_client_interactivity_data() {
 302          _deprecated_function( __METHOD__, '6.7.0' );
 303      }
 304  
 305      /**
 306       * Set client-side interactivity-router data.
 307       *
 308       * Once in the browser, the state will be parsed and used to hydrate the client-side
 309       * interactivity stores and the configuration will be available using a `getConfig` utility.
 310       *
 311       * @since 6.7.0
 312       *
 313       * @param array $data Data to filter.
 314       * @return array Data for the Interactivity Router script module.
 315       */
 316      public function filter_script_module_interactivity_router_data( array $data ): array {
 317          $data['i18n']['loading'] = __( 'Loading page, please wait.' );
 318          $data['i18n']['loaded']  = __( 'Page Loaded.' );
 319          return $data;
 320      }
 321  
 322      /**
 323       * Set client-side interactivity data.
 324       *
 325       * Once in the browser, the state will be parsed and used to hydrate the client-side
 326       * interactivity stores and the configuration will be available using a `getConfig` utility.
 327       *
 328       * @since 6.7.0
 329       * @since 6.9.0 Serializes derived state props accessed during directive processing.
 330       *
 331       * @param array $data Data to filter.
 332       * @return array Data for the Interactivity API script module.
 333       */
 334  	public function filter_script_module_interactivity_data( array $data ): array {
 335          if (
 336              empty( $this->state_data ) &&
 337              empty( $this->config_data ) &&
 338              empty( $this->derived_state_closures )
 339          ) {
 340              return $data;
 341          }
 342  
 343          $config = array();
 344          foreach ( $this->config_data as $key => $value ) {
 345              if ( ! empty( $value ) ) {
 346                  $config[ $key ] = $value;
 347              }
 348          }
 349          if ( ! empty( $config ) ) {
 350              $data['config'] = $config;
 351          }
 352  
 353          $state = array();
 354          foreach ( $this->state_data as $key => $value ) {
 355              if ( ! empty( $value ) ) {
 356                  $state[ $key ] = $value;
 357              }
 358          }
 359          if ( ! empty( $state ) ) {
 360              $data['state'] = $state;
 361          }
 362  
 363          $derived_props = array();
 364          foreach ( $this->derived_state_closures as $key => $value ) {
 365              if ( ! empty( $value ) ) {
 366                  $derived_props[ $key ] = $value;
 367              }
 368          }
 369          if ( ! empty( $derived_props ) ) {
 370              $data['derivedStateClosures'] = $derived_props;
 371          }
 372  
 373          return $data;
 374      }
 375  
 376      /**
 377       * Returns the latest value on the context stack with the passed namespace.
 378       *
 379       * When the namespace is omitted, it uses the current namespace on the
 380       * namespace stack during a `process_directives` call.
 381       *
 382       * @since 6.6.0
 383       *
 384       * @param string|null $store_namespace Optional. The unique store namespace identifier.
 385       */
 386  	public function get_context( ?string $store_namespace = null ): array {
 387          if ( null === $this->context_stack ) {
 388              _doing_it_wrong(
 389                  __METHOD__,
 390                  __( 'The context can only be read during directive processing.' ),
 391                  '6.6.0'
 392              );
 393              return array();
 394          }
 395  
 396          if ( ! $store_namespace ) {
 397              if ( null !== $store_namespace ) {
 398                  _doing_it_wrong(
 399                      __METHOD__,
 400                      __( 'The namespace should be a non-empty string.' ),
 401                      '6.6.0'
 402                  );
 403                  return array();
 404              }
 405  
 406              $store_namespace = end( $this->namespace_stack );
 407          }
 408  
 409          $context = end( $this->context_stack );
 410  
 411          return ( $store_namespace && $context && isset( $context[ $store_namespace ] ) )
 412              ? $context[ $store_namespace ]
 413              : array();
 414      }
 415  
 416      /**
 417       * Returns an array representation of the current element being processed.
 418       *
 419       * The returned array contains a copy of the element attributes.
 420       *
 421       * @since 6.7.0
 422       *
 423       * @return array{attributes: array<string, string|bool>}|null Current element.
 424       */
 425  	public function get_element(): ?array {
 426          if ( null === $this->current_element ) {
 427              _doing_it_wrong(
 428                  __METHOD__,
 429                  __( 'The element can only be read during directive processing.' ),
 430                  '6.7.0'
 431              );
 432          }
 433  
 434          return $this->current_element;
 435      }
 436  
 437      /**
 438       * Registers the `@wordpress/interactivity` script modules.
 439       *
 440       * @since 6.5.0
 441       * @deprecated 6.7.0 Script Modules registration is handled by {@see wp_default_script_modules()}.
 442       */
 443  	public function register_script_modules() {
 444          _deprecated_function( __METHOD__, '6.7.0', 'wp_default_script_modules' );
 445      }
 446  
 447      /**
 448       * Adds the necessary hooks for the Interactivity API.
 449       *
 450       * @since 6.5.0
 451       * @since 6.9.0 Adds support for client-side navigation in script modules.
 452       */
 453  	public function add_hooks() {
 454          add_filter( 'script_module_data_@wordpress/interactivity', array( $this, 'filter_script_module_interactivity_data' ) );
 455          add_filter( 'script_module_data_@wordpress/interactivity-router', array( $this, 'filter_script_module_interactivity_router_data' ) );
 456          add_filter( 'wp_script_attributes', array( $this, 'add_load_on_client_navigation_attribute_to_script_modules' ) );
 457      }
 458  
 459      /**
 460       * Adds the `data-wp-router-options` attribute to script modules that
 461       * support client-side navigation.
 462       *
 463       * This method filters the script attributes to include loading instructions
 464       * for the Interactivity API router, indicating which modules can be loaded
 465       * during client-side navigation.
 466       *
 467       * @since 6.9.0
 468       *
 469       * @param array<string, string|true>|mixed $attributes The script tag attributes.
 470       * @return array The modified script tag attributes.
 471       */
 472      public function add_load_on_client_navigation_attribute_to_script_modules( $attributes ) {
 473          if (
 474              is_array( $attributes ) &&
 475              isset( $attributes['type'], $attributes['id'] ) &&
 476              'module' === $attributes['type'] &&
 477              array_key_exists(
 478                  preg_replace( '/-js-module$/', '', $attributes['id'] ),
 479                  $this->script_modules_that_can_load_on_client_navigation
 480              )
 481          ) {
 482              $attributes['data-wp-router-options'] = wp_json_encode( array( 'loadOnClientNavigation' => true ) );
 483          }
 484          return $attributes;
 485      }
 486  
 487      /**
 488       * Marks a script module as compatible with client-side navigation.
 489       *
 490       * This method registers a script module to be loaded during client-side
 491       * navigation in the Interactivity API router. Script modules marked with
 492       * this method will have the `loadOnClientNavigation` option enabled in the
 493       * `data-wp-router-options` directive.
 494       *
 495       * @since 6.9.0
 496       *
 497       * @param string $script_module_id The script module identifier.
 498       */
 499      public function add_client_navigation_support_to_script_module( string $script_module_id ) {
 500          $this->script_modules_that_can_load_on_client_navigation[ $script_module_id ] = true;
 501      }
 502  
 503      /**
 504       * Processes the interactivity directives contained within the HTML content
 505       * and updates the markup accordingly.
 506       *
 507       * @since 6.5.0
 508       * @since 7.2.0 The output includes the trusted HTML that `data-wp-html` renders. That HTML is
 509       *              added once the whole call has finished, so it is never processed.
 510       * @since 7.2.0 It can be called while another call is processing, as from a derived-state
 511       *              closure that renders markup. That call processes its HTML on its own, and the
 512       *              processing of the other call continues as before it once it returns.
 513       *
 514       * @param string $html The HTML content to process.
 515       * @return string The processed HTML content. It returns the original content when the HTML contains unbalanced tags.
 516       */
 517  	public function process_directives( string $html ): string {
 518          if ( ! str_contains( $html, 'data-wp-' ) ) {
 519              return $html;
 520          }
 521  
 522          $caller_state = array(
 523              $this->namespace_stack,
 524              $this->island_namespace_stack,
 525              $this->context_stack,
 526              $this->current_element,
 527              $this->html_placeholder_marker,
 528              $this->html_placeholders,
 529          );
 530  
 531          $this->namespace_stack        = array();
 532          $this->island_namespace_stack = array();
 533          $this->context_stack          = array();
 534          $this->current_element        = null;
 535  
 536          /*
 537           * The trusted HTML of `data-wp-html` hosts never takes part in the
 538           * processing: each host receives a placeholder comment, and the comments
 539           * are replaced by their HTML once the result is known. The call starts
 540           * without the marker that identifies them: the first host that renders
 541           * draws it, so a call without such a host does no marker work.
 542           */
 543          $this->html_placeholder_marker = '';
 544          $this->html_placeholders       = array();
 545  
 546          try {
 547              $result       = $this->_process_directives( $html );
 548              $placeholders = $this->html_placeholders;
 549          } finally {
 550              list(
 551                  $this->namespace_stack,
 552                  $this->island_namespace_stack,
 553                  $this->context_stack,
 554                  $this->current_element,
 555                  $this->html_placeholder_marker,
 556                  $this->html_placeholders
 557              ) = $caller_state;
 558          }
 559  
 560          if ( null === $result ) {
 561              return $html;
 562          }
 563  
 564          // `strtr()` never searches the replacements it has inserted.
 565          return strtr( $result, $placeholders );
 566      }
 567  
 568      /**
 569       * Processes the interactivity directives contained within the HTML content
 570       * and updates the markup accordingly.
 571       *
 572       * It uses the WP_Interactivity_API instance's context and namespace stacks,
 573       * which every call made during one `process_directives()` call shares.
 574       *
 575       * This method returns null if the HTML contains unbalanced tags.
 576       *
 577       * @since 6.6.0
 578       *
 579       * @param string $html The HTML content to process.
 580       * @return string|null The processed HTML content. It returns null when the HTML contains unbalanced tags.
 581       */
 582  	private function _process_directives( string $html ) {
 583          $p          = new WP_Interactivity_API_Directives_Processor( $html );
 584          $tag_stack  = array();
 585          $unbalanced = false;
 586  
 587          $directive_processor_prefixes          = array_keys( self::$directive_processors );
 588          $directive_processor_prefixes_reversed = array_reverse( $directive_processor_prefixes );
 589  
 590          /*
 591           * Save the current size for each stack to restore them in case
 592           * the processing finds unbalanced tags.
 593           */
 594          $namespace_stack_size        = count( $this->namespace_stack );
 595          $island_namespace_stack_size = count( $this->island_namespace_stack );
 596          $context_stack_size          = count( $this->context_stack );
 597  
 598          while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) {
 599              $tag_name = $p->get_tag();
 600  
 601              /*
 602               * Directives inside SVG and MATH tags are not processed,
 603               * as they are not compatible with the Tag Processor yet.
 604               * We still process the rest of the HTML.
 605               */
 606              if ( 'SVG' === $tag_name || 'MATH' === $tag_name ) {
 607                  if ( $p->get_attribute_names_with_prefix( 'data-wp-' ) ) {
 608                      /* translators: 1: SVG or MATH HTML tag, 2: Namespace of the interactive block. */
 609                      $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 ) );
 610                      _doing_it_wrong( __METHOD__, $message, '6.6.0' );
 611                  }
 612                  $p->skip_to_tag_closer();
 613                  continue;
 614              }
 615  
 616              if ( $p->is_tag_closer() ) {
 617                  list( $opening_tag_name, $directives_prefixes ) = ! empty( $tag_stack ) ? end( $tag_stack ) : array( null, null );
 618  
 619                  if ( 0 === count( $tag_stack ) || $opening_tag_name !== $tag_name ) {
 620  
 621                      /*
 622                       * If the tag stack is empty or the matching opening tag is not the
 623                       * same than the closing tag, it means the HTML is unbalanced and it
 624                       * stops processing it.
 625                       */
 626                      $unbalanced = true;
 627                      break;
 628                  } else {
 629                      // Remove the last tag from the stack.
 630                      array_pop( $tag_stack );
 631                  }
 632              } else {
 633                  $each_child_attrs = $p->get_attribute_names_with_prefix( 'data-wp-each-child' );
 634                  if ( null === $each_child_attrs ) {
 635                      continue;
 636                  }
 637  
 638                  if ( 0 !== count( $each_child_attrs ) ) {
 639                      /*
 640                       * If the tag has a `data-wp-each-child` directive, jump to its closer
 641                       * tag because those tags have already been processed.
 642                       */
 643                      $p->next_balanced_tag_closer_tag();
 644                      continue;
 645                  } else {
 646                      $directives_prefixes = array();
 647  
 648                      // Checks if there is a server directive processor registered for each directive.
 649                      foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) {
 650                          $parsed_directive = $this->parse_directive_name( $attribute_name );
 651                          if ( empty( $parsed_directive ) ) {
 652                              continue;
 653                          }
 654                          $directive_prefix = 'data-wp-' . $parsed_directive['prefix'];
 655                          if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) {
 656                              $directives_prefixes[] = $directive_prefix;
 657                          }
 658                      }
 659  
 660                      /*
 661                       * If this tag will visit its closer tag, it adds it to the tag stack
 662                       * so it can process its closing tag and check for unbalanced tags.
 663                       */
 664                      if ( $p->has_and_visits_its_closer_tag() ) {
 665                          $tag_stack[] = array( $tag_name, $directives_prefixes );
 666                      }
 667                  }
 668              }
 669              /*
 670               * If the matching opener tag didn't have any directives, it can skip the
 671               * processing.
 672               */
 673              if ( 0 === count( $directives_prefixes ) ) {
 674                  continue;
 675              }
 676  
 677              // Directive processing might be different depending on if it is entering the tag or exiting it.
 678              $modes = array(
 679                  'enter' => ! $p->is_tag_closer(),
 680                  'exit'  => $p->is_tag_closer() || ! $p->has_and_visits_its_closer_tag(),
 681              );
 682  
 683              // Get the element attributes to include them in the element representation.
 684              $element_attrs = array();
 685              $attr_names    = $p->get_attribute_names_with_prefix( '' ) ?? array();
 686  
 687              foreach ( $attr_names as $name ) {
 688                  $element_attrs[ $name ] = $p->get_attribute( $name );
 689              }
 690  
 691              // Assign the current element right before running its directive processors.
 692              $this->current_element = array(
 693                  'attributes' => $element_attrs,
 694              );
 695  
 696              $is_content_consumed = false;
 697  
 698              foreach ( $modes as $mode => $should_run ) {
 699                  // An element whose content was consumed is completed as at its closer tag.
 700                  if ( 'exit' === $mode && $is_content_consumed ) {
 701                      array_pop( $tag_stack );
 702                      $should_run = true;
 703                  }
 704  
 705                  if ( ! $should_run ) {
 706                      continue;
 707                  }
 708  
 709                  /*
 710                   * Sorts the attributes by the order of the `directives_processor` array
 711                   * and checks what directives are present in this element.
 712                   */
 713                  $existing_directives_prefixes = array_intersect(
 714                      'enter' === $mode ? $directive_processor_prefixes : $directive_processor_prefixes_reversed,
 715                      $directives_prefixes
 716                  );
 717                  foreach ( $existing_directives_prefixes as $directive_prefix ) {
 718                      $func = is_array( self::$directive_processors[ $directive_prefix ] )
 719                          ? self::$directive_processors[ $directive_prefix ]
 720                          : array( $this, self::$directive_processors[ $directive_prefix ] );
 721  
 722                      call_user_func_array( $func, array( $p, $mode, &$tag_stack ) );
 723  
 724                      /*
 725                       * A processor that consumes the content of the element it enters
 726                       * leaves the cursor on the element's closer tag, where no other
 727                       * processor enters. `data-wp-each` completes its `template` itself.
 728                       */
 729                      if ( 'enter' === $mode && $p->is_tag_closer() ) {
 730                          $is_content_consumed = 'data-wp-each' !== $directive_prefix;
 731                          break;
 732                      }
 733                  }
 734              }
 735  
 736              // Clear the current element.
 737              $this->current_element = null;
 738          }
 739  
 740          if ( $unbalanced ) {
 741              // Reset the namespace and context stacks to their previous values.
 742              array_splice( $this->namespace_stack, $namespace_stack_size );
 743              array_splice( $this->island_namespace_stack, $island_namespace_stack_size );
 744              array_splice( $this->context_stack, $context_stack_size );
 745          }
 746  
 747          /*
 748           * It returns null if the HTML is unbalanced because unbalanced HTML is
 749           * not safe to process. In that case, the Interactivity API runtime will
 750           * update the HTML on the client side during the hydration. It will display
 751           * a notice to the developer in the console to inform them about the issue.
 752           */
 753          if ( $unbalanced || 0 < count( $tag_stack ) ) {
 754              return null;
 755          }
 756  
 757          return $p->get_updated_html();
 758      }
 759  
 760      /**
 761       * Evaluates the reference path passed to a directive based on the current
 762       * store namespace, state and context.
 763       *
 764       * @since 6.5.0
 765       * @since 6.6.0 The function now adds a warning when the namespace is null, falsy, or the directive value is empty.
 766       * @since 6.6.0 Removed `default_namespace` and `context` arguments.
 767       * @since 6.6.0 Add support for derived state.
 768       * @since 6.9.0 Receive $entry as an argument instead of the directive value string.
 769       *
 770       * @param array $entry An array containing a whole directive entry with its namespace, value, suffix, or unique ID.
 771       * @return mixed|null The result of the evaluation. Null if the reference path doesn't exist or the namespace is falsy.
 772       */
 773  	private function evaluate( $entry ) {
 774          $context                               = end( $this->context_stack );
 775          ['namespace' => $ns, 'value' => $path] = $entry;
 776  
 777          if ( ! $ns || ! $path ) {
 778              /* translators: %s: The directive value referenced. */
 779              $message = sprintf( __( 'Namespace or reference path cannot be empty. Directive value referenced: %s' ), json_encode( $entry ) );
 780              _doing_it_wrong( __METHOD__, $message, '6.6.0' );
 781              return null;
 782          }
 783  
 784          $store = array(
 785              'state'   => $this->state_data[ $ns ] ?? array(),
 786              'context' => $context[ $ns ] ?? array(),
 787          );
 788  
 789          // Checks if the reference path is preceded by a negation operator (!).
 790          $should_negate_value = '!' === $path[0];
 791          $path                = $should_negate_value ? substr( $path, 1 ) : $path;
 792  
 793          // Extracts the value from the store using the reference path.
 794          $path_segments = explode( '.', $path );
 795          $current       = $store;
 796          foreach ( $path_segments as $index => $path_segment ) {
 797              /*
 798               * Special case for numeric arrays and strings. Add length
 799               * property mimicking JavaScript behavior.
 800               *
 801               * @since 6.8.0
 802               */
 803              if ( 'length' === $path_segment ) {
 804                  if ( is_array( $current ) && array_is_list( $current ) ) {
 805                      $current = count( $current );
 806                      break;
 807                  }
 808  
 809                  if ( is_string( $current ) ) {
 810                      /*
 811                       * Differences in encoding between PHP strings and
 812                       * JavaScript mean that it's complicated to calculate
 813                       * the string length JavaScript would see from PHP.
 814                       * `strlen` is a reasonable approximation.
 815                       *
 816                       * Users that desire a more precise length likely have
 817                       * more precise needs than "bytelength" and should
 818                       * implement their own length calculation in derived
 819                       * state taking into account encoding and their desired
 820                       * output (codepoints, graphemes, bytes, etc.).
 821                       */
 822                      $current = strlen( $current );
 823                      break;
 824                  }
 825              }
 826  
 827              if ( ( is_array( $current ) || $current instanceof ArrayAccess ) && isset( $current[ $path_segment ] ) ) {
 828                  $current = $current[ $path_segment ];
 829              } elseif ( is_object( $current ) && isset( $current->$path_segment ) ) {
 830                  $current = $current->$path_segment;
 831              } else {
 832                  $current = null;
 833                  break;
 834              }
 835  
 836              if ( $current instanceof Closure ) {
 837                  /*
 838                   * This state getter's namespace is added to the stack so that
 839                   * `state()` or `get_config()` read that namespace when called
 840                   * without specifying one.
 841                   */
 842                  array_push( $this->namespace_stack, $ns );
 843                  try {
 844                      $current = $current();
 845  
 846                      /*
 847                       * Tracks derived state properties that are accessed during
 848                       * rendering.
 849                       *
 850                       * @since 6.9.0
 851                       */
 852                      $this->derived_state_closures[ $ns ] ??= array();
 853  
 854                      // Builds path for the current property and add it to tracking if not already present.
 855                      $current_path = implode( '.', array_slice( $path_segments, 0, $index + 1 ) );
 856                      if ( ! in_array( $current_path, $this->derived_state_closures[ $ns ], true ) ) {
 857                          $this->derived_state_closures[ $ns ][] = $current_path;
 858                      }
 859                  } catch ( Throwable $e ) {
 860                      _doing_it_wrong(
 861                          __METHOD__,
 862                          sprintf(
 863                              /* translators: 1: Path pointing to an Interactivity API state property, 2: Namespace for an Interactivity API store. */
 864                              __( 'Uncaught error executing a derived state callback with path "%1$s" and namespace "%2$s".' ),
 865                              $path,
 866                              $ns
 867                          ),
 868                          '6.6.0'
 869                      );
 870                      return null;
 871                  } finally {
 872                      // Remove the property's namespace from the stack.
 873                      array_pop( $this->namespace_stack );
 874                  }
 875              }
 876          }
 877  
 878          // Returns the opposite if it contains a negation operator (!).
 879          return $should_negate_value ? ! $current : $current;
 880      }
 881  
 882      /**
 883       * Parse the directive name to extract the following parts:
 884       * - Prefix: The main directive name without "data-wp-". It cannot begin with a hyphen.
 885       * - Suffix: An optional suffix used during directive processing, extracted after the first double hyphen "--".
 886       * - Unique ID: An optional unique identifier, extracted after the first triple hyphen "---".
 887       *
 888       * This function has an equivalent version for the client side.
 889       * See `parseDirectiveName` in https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/src/vdom.ts:
 890       *
 891       * An empty suffix or unique ID is normalized to null, but the string "0" is preserved. The
 892       * client's `|| null` discards only the empty string, since every non-empty string is truthy in
 893       * JavaScript. Do not use empty() for these checks: it would discard "0" and diverge from the
 894       * client.
 895       *
 896       * @see Tests_Interactivity_API_WpInteractivityAPI::test_parse_directive_name() for examples in the test inputs.
 897       *
 898       * @since 6.9.0
 899       *
 900       * @param string $directive_name The directive attribute name.
 901       * @return array|null An array containing the directive prefix, optional suffix, and optional unique ID, or null if the directive name cannot be parsed.
 902       * @phpstan-return array{
 903       *     prefix: non-empty-string,
 904       *     suffix: non-empty-string|null,
 905       *     unique_id: non-empty-string|null,
 906       * }|null
 907       */
 908  	private function parse_directive_name( string $directive_name ): ?array {
 909          // Remove the first 8 characters (assumes "data-wp-" prefix)
 910          $name = (string) substr( $directive_name, 8 );
 911  
 912          // Ensure the name only contains valid characters (anything a-z, A-Z, 0-9, -, or _).
 913          if ( 1 !== preg_match( '/^[a-zA-Z0-9\-_]+$/', $name ) ) {
 914              return null;
 915          }
 916  
 917          // Find the first occurrence of '--' to separate the prefix.
 918          $suffix_index = strpos( $name, '--' );
 919  
 920          /*
 921           * A prefix cannot begin with a hyphen, so a name which does is not a directive at all. This
 922           * covers both a lone leading hyphen, as in "data-wp--bind", and a leading double hyphen, as
 923           * in "data-wp---foo", where treating the hyphens as a suffix separator would instead leave
 924           * the prefix empty. It also covers "data-wp----unique-id", where only a unique ID is supplied
 925           * without any prefix or suffix.
 926           */
 927          if ( 0 === $suffix_index || '-' === $name[0] ) {
 928              return null;
 929          }
 930  
 931          // Without a '--' the whole name is the prefix. (This naturally also means there is no unique ID after '---'.)
 932          if ( false === $suffix_index ) {
 933              return array(
 934                  'prefix'    => $name,
 935                  'suffix'    => null,
 936                  'unique_id' => null,
 937              );
 938          }
 939  
 940          $prefix    = substr( $name, 0, $suffix_index );
 941          $remaining = substr( $name, $suffix_index );
 942  
 943          // If remaining starts with '---' but not '----', it's a unique_id
 944          if ( 3 === strspn( $remaining, '-' ) ) {
 945              $unique_id = (string) substr( $remaining, 3 );
 946              return array(
 947                  'prefix'    => $prefix,
 948                  'suffix'    => null,
 949                  'unique_id' => '' === $unique_id ? null : $unique_id,
 950              );
 951          }
 952  
 953          // Otherwise, remove the first two dashes for a potential suffix
 954          $suffix = (string) substr( $remaining, 2 );
 955  
 956          // Look for '---' in the suffix for a unique_id
 957          $unique_id_index = strpos( $suffix, '---' );
 958  
 959          if ( false !== $unique_id_index && '-' !== ( $suffix[ $unique_id_index + 3 ] ?? '' ) ) {
 960              $unique_id = (string) substr( $suffix, $unique_id_index + 3 );
 961              $suffix    = (string) substr( $suffix, 0, $unique_id_index );
 962              return array(
 963                  'prefix'    => $prefix,
 964                  'suffix'    => '' === $suffix ? null : $suffix,
 965                  'unique_id' => '' === $unique_id ? null : $unique_id,
 966              );
 967          }
 968  
 969          return array(
 970              'prefix'    => $prefix,
 971              'suffix'    => '' === $suffix ? null : $suffix,
 972              'unique_id' => null,
 973          );
 974      }
 975  
 976      /**
 977       * Parses and extracts the namespace and reference path from the given
 978       * directive attribute value.
 979       *
 980       * If the value doesn't contain an explicit namespace, it returns the
 981       * default one. If the value contains a JSON object instead of a reference
 982       * path, the function tries to parse it and return the resulting array. If
 983       * the value contains strings that represent booleans ("true" and "false"),
 984       * numbers ("1" and "1.2") or "null", the function also transform them to
 985       * regular booleans, numbers and `null`.
 986       *
 987       * Example:
 988       *
 989       *     extract_directive_value( 'actions.foo', 'myPlugin' )                      => array( 'myPlugin', 'actions.foo' )
 990       *     extract_directive_value( 'otherPlugin::actions.foo', 'myPlugin' )         => array( 'otherPlugin', 'actions.foo' )
 991       *     extract_directive_value( '{ "isOpen": false }', 'myPlugin' )              => array( 'myPlugin', array( 'isOpen' => false ) )
 992       *     extract_directive_value( 'otherPlugin::{ "isOpen": false }', 'myPlugin' ) => array( 'otherPlugin', array( 'isOpen' => false ) )
 993       *
 994       * @since 6.5.0
 995       *
 996       * @param string|true $directive_value   The directive attribute value. It can be `true` when it's a boolean
 997       *                                       attribute.
 998       * @param string|null $default_namespace Optional. The default namespace if none is explicitly defined.
 999       * @return array An array containing the namespace in the first item and the JSON, the reference path, or null on the
1000       *               second item.
1001       * @phpstan-return array{ 0: string|null, 1: mixed }
1002       */
1003  	private function extract_directive_value( $directive_value, $default_namespace = null ): array {
1004          if ( empty( $directive_value ) || is_bool( $directive_value ) ) {
1005              return array( $default_namespace, null );
1006          }
1007  
1008          // Replaces the value and namespace if there is a namespace in the value.
1009          if ( 1 === preg_match( '/^([\w\-_\/]+)::./', $directive_value ) ) {
1010              list($default_namespace, $directive_value) = explode( '::', $directive_value, 2 );
1011          }
1012  
1013          /*
1014           * Tries to decode the value as a JSON object. If it fails and the value
1015           * isn't `null`, it returns the value as it is. Otherwise, it returns the
1016           * decoded JSON or null for the string `null`.
1017           */
1018          $decoded_json = json_decode( $directive_value, true );
1019          if ( null !== $decoded_json || 'null' === $directive_value ) {
1020              $directive_value = $decoded_json;
1021          }
1022  
1023          return array( $default_namespace, $directive_value );
1024      }
1025  
1026      /**
1027       * Parse the HTML element and get all the valid directives with the given prefix.
1028       *
1029       * @since 6.9.0
1030       *
1031       * @param WP_Interactivity_API_Directives_Processor $p      The directives processor instance.
1032       * @param string                                    $prefix The directive prefix to filter by.
1033       * @return array An array of entries containing the directive namespace, value, suffix, unique ID, and attribute name.
1034       * @phpstan-return list<array{
1035       *     namespace: string|null,
1036       *     value: mixed,
1037       *     suffix: string|null,
1038       *     unique_id: string|null,
1039       *     attribute_name: non-empty-string,
1040       * }>
1041       */
1042  	private function get_directive_entries( WP_Interactivity_API_Directives_Processor $p, string $prefix ): array {
1043          $directive_attributes = $p->get_attribute_names_with_prefix( 'data-wp-' . $prefix );
1044          if ( null === $directive_attributes ) {
1045              return array();
1046          }
1047  
1048          $entries = array();
1049          foreach ( $directive_attributes as $attribute_name ) {
1050              $parsed_directive = $this->parse_directive_name( $attribute_name );
1051              if ( null === $parsed_directive ) {
1052                  continue;
1053              }
1054  
1055              [ 'prefix' => $attr_prefix, 'suffix' => $suffix, 'unique_id' => $unique_id ] = $parsed_directive;
1056              // Ensure it is the desired directive.
1057              if ( $prefix !== $attr_prefix ) {
1058                  continue;
1059              }
1060              $attribute_value = $p->get_attribute( $attribute_name );
1061              if ( null === $attribute_value ) {
1062                  continue;
1063              }
1064              /*
1065               * The namespace stack can hold false, which data_wp_interactive_processor() pushes for a
1066               * `data-wp-interactive` whose namespace is invalid and which has no enclosing one to inherit. Only a
1067               * string names a store, so anything else counts as no default namespace at all.
1068               */
1069              $default_namespace = array_last( $this->namespace_stack ?? array() );
1070              if ( ! is_string( $default_namespace ) ) {
1071                  $default_namespace = null;
1072              }
1073  
1074              list( $namespace, $value ) = $this->extract_directive_value( $attribute_value, $default_namespace );
1075              $entries[]                 = array(
1076                  'namespace'      => $namespace,
1077                  'value'          => $value,
1078                  'suffix'         => $suffix,
1079                  'unique_id'      => $unique_id,
1080                  'attribute_name' => $attribute_name,
1081              );
1082          }
1083          // Sort directive entries to ensure stable ordering with the client.
1084          // Put nulls first, then sort by suffix and finally by uniqueIds.
1085          usort(
1086              $entries,
1087              function ( $a, $b ) {
1088                  $a_suffix = $a['suffix'] ?? '';
1089                  $b_suffix = $b['suffix'] ?? '';
1090                  if ( $a_suffix !== $b_suffix ) {
1091                      return $a_suffix <=> $b_suffix;
1092                  }
1093                  $a_id = $a['unique_id'] ?? '';
1094                  $b_id = $b['unique_id'] ?? '';
1095                  return $a_id <=> $b_id;
1096              }
1097          );
1098          return $entries;
1099      }
1100  
1101      /**
1102       * Transforms a kebab-case string to camelCase.
1103       *
1104       * @since 6.5.0
1105       *
1106       * @param string $str The kebab-case string to transform to camelCase.
1107       * @return string The transformed camelCase string.
1108       */
1109  	private function kebab_to_camel_case( string $str ): string {
1110          return lcfirst(
1111              preg_replace_callback(
1112                  '/(-)([a-z])/',
1113                  function ( $matches ) {
1114                      return strtoupper( $matches[2] );
1115                  },
1116                  strtolower( rtrim( $str, '-' ) )
1117              )
1118          );
1119      }
1120  
1121      /**
1122       * Processes the `data-wp-interactive` directive.
1123       *
1124       * It adds the default store namespace defined in the directive value to the
1125       * stack so that it's available for the nested interactivity elements.
1126       *
1127       * @since 6.5.0
1128       * @since 7.2.0 Also records the namespace that the directive itself names, for `data-wp-html`.
1129       *
1130       * @param WP_Interactivity_API_Directives_Processor $p    The directives processor instance.
1131       * @param string                                    $mode Whether the processing is entering or exiting the tag.
1132       */
1133  	private function data_wp_interactive_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
1134          // When exiting tags, it removes the last namespace from the stacks.
1135          if ( 'exit' === $mode ) {
1136              array_pop( $this->namespace_stack );
1137              array_pop( $this->island_namespace_stack );
1138              return;
1139          }
1140  
1141          // Tries to decode the `data-wp-interactive` attribute value.
1142          $attribute_value = $p->get_attribute( 'data-wp-interactive' );
1143  
1144          /*
1145           * Pushes the newly defined namespace or the current one if the
1146           * `data-wp-interactive` definition was invalid or does not contain a
1147           * namespace. It does so because the function pops out the current namespace
1148           * from the stack whenever it finds a `data-wp-interactive`'s closing tag,
1149           * independently of whether the previous `data-wp-interactive` definition
1150           * contained a valid namespace.
1151           */
1152          $new_namespace = null;
1153          $decoded_json  = null;
1154          if ( is_string( $attribute_value ) && ! empty( $attribute_value ) ) {
1155              $decoded_json = json_decode( $attribute_value, true );
1156              if ( is_array( $decoded_json ) ) {
1157                  $new_namespace = $decoded_json['namespace'] ?? null;
1158              } else {
1159                  $new_namespace = $attribute_value;
1160              }
1161          }
1162          $this->namespace_stack[] = ( $new_namespace && 1 === preg_match( '/^([\w\-_\/]+)/', $new_namespace ) )
1163              ? $new_namespace
1164              : end( $this->namespace_stack );
1165  
1166          /*
1167           * Only the attribute named exactly `data-wp-interactive` makes an island,
1168           * so an element with only suffixed or unique-ID entries keeps the
1169           * enclosing island's namespace. The client reads a JSON object only when
1170           * the value starts with `{`, and keeps any other value as given.
1171           */
1172          if ( null === $attribute_value ) {
1173              $this->island_namespace_stack[] = array_last( $this->island_namespace_stack );
1174              return;
1175          }
1176          $island_namespace = is_string( $attribute_value ) ? $attribute_value : null;
1177          if ( is_array( $decoded_json ) && 1 === preg_match( '/^\s*\{/', $attribute_value ) ) {
1178              $island_namespace = is_string( $decoded_json['namespace'] ?? null ) ? $decoded_json['namespace'] : null;
1179          }
1180          if ( '' === $island_namespace || ( null !== $island_namespace && self::is_reserved_word( $island_namespace ) ) ) {
1181              $island_namespace = null;
1182          }
1183          $this->island_namespace_stack[] = $island_namespace;
1184      }
1185  
1186      /**
1187       * Processes the `data-wp-context` directive.
1188       *
1189       * It adds the context defined in the directive value to the stack so that
1190       * it's available for the nested interactivity elements.
1191       *
1192       * @since 6.5.0
1193       *
1194       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
1195       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
1196       */
1197  	private function data_wp_context_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
1198          // When exiting tags, it removes the last context from the stack.
1199          if ( 'exit' === $mode ) {
1200              array_pop( $this->context_stack );
1201              return;
1202          }
1203  
1204          $entries = $this->get_directive_entries( $p, 'context' );
1205          $context = end( $this->context_stack ) !== false ? end( $this->context_stack ) : array();
1206          foreach ( $entries as $entry ) {
1207              if ( null !== $entry['suffix'] ) {
1208                  continue;
1209              }
1210  
1211              /*
1212               * A context with no namespace has nothing to be stored under, so the inherited context is left as it
1213               * is. Using the namespace as an array key regardless would coerce null to an empty string, which PHP
1214               * 8.5 deprecates, and would store the context where no reference can address it anyway.
1215               */
1216              if ( null === $entry['namespace'] ) {
1217                  continue;
1218              }
1219  
1220              $context = array_replace_recursive(
1221                  $context,
1222                  array( $entry['namespace'] => is_array( $entry['value'] ) ? $entry['value'] : array() )
1223              );
1224          }
1225          $this->context_stack[] = $context;
1226      }
1227  
1228      /**
1229       * Processes the `data-wp-bind` directive.
1230       *
1231       * It updates or removes the bound attributes based on the evaluation of its
1232       * associated reference.
1233       *
1234       * @since 6.5.0
1235       * @since 7.1.0 An object is resolved to whatever it serializes to for the client, a number is formatted by the
1236       *              JSON encoder, and a value which cannot be sent to the client is rejected rather than passed to
1237       *              WP_HTML_Tag_Processor::set_attribute().
1238       * @since 7.2.0 Event handler bind directives are removed.
1239       *
1240       * @param WP_Interactivity_API_Directives_Processor $p    The directives processor instance.
1241       * @param string                                    $mode Whether the processing is entering or exiting the tag.
1242       */
1243  	private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ): void {
1244          if ( 'enter' === $mode ) {
1245              $event_handler_prefix = 'data-wp-bind--on';
1246              $entries              = $this->get_directive_entries( $p, 'bind' );
1247              foreach ( $entries as $entry ) {
1248                  // Binding event handler attributes is not supported.
1249                  if ( str_starts_with( $entry['attribute_name'], $event_handler_prefix ) ) {
1250                      $p->remove_attribute( $entry['attribute_name'] );
1251                      _doing_it_wrong(
1252                          __METHOD__,
1253                          sprintf(
1254                              /* translators: %s: The directive, e.g. data-wp-on--click. */
1255                              __( 'Binding event handler attributes is not supported. Please use "%s" instead.' ),
1256                              esc_attr( 'data-wp-on--' . substr( $entry['attribute_name'], strlen( $event_handler_prefix ) ) )
1257                          ),
1258                          '6.9.2'
1259                      );
1260                      continue;
1261                  }
1262  
1263                  if ( empty( $entry['suffix'] ) || null !== $entry['unique_id'] ) {
1264                      continue;
1265                  }
1266  
1267                  /*
1268                   * Remove a binding that would change an `object` attribute whose value KSES checks,
1269                   * so that a client runtime without this check does not apply it either.
1270                   */
1271                  if ( 'OBJECT' === $p->get_tag() && ! self::is_bindable_object_attribute( $entry['suffix'] ) ) {
1272                      $p->remove_attribute( $entry['attribute_name'] );
1273                      _doing_it_wrong(
1274                          __METHOD__,
1275                          sprintf(
1276                              /* translators: %s: The attribute name. */
1277                              __( 'The "%s" attribute cannot be bound on an "object" element, so the binding was ignored.' ),
1278                              esc_html( $entry['suffix'] )
1279                          ),
1280                          '7.1.4'
1281                      );
1282                      continue;
1283                  }
1284  
1285                  $result = $this->evaluate( $entry );
1286  
1287                  /*
1288                   * An object is resolved to whatever it serializes to. When the reference points to a value stored
1289                   * in state or context, that is the value the client receives for it when the store is hydrated.
1290                   * A derived state closure is never serialized, so there the client value comes from the derived
1291                   * state's client-side implementation instead; the resolution is still applied so that both origins
1292                   * behave the same. Round-tripping through the JSON encoder rather than calling
1293                   * JsonSerializable::jsonSerialize() directly keeps this resolution identical to the client's,
1294                   * including for an object which serializes to another serializable object. When the encoding fails
1295                   * the object is left in place, to be reported as a usage error below. Note that it rarely does
1296                   * fail: wp_json_encode() retries through _wp_json_sanity_check(), which rebuilds the object from
1297                   * its public properties and so ignores jsonSerialize() altogether. An object whose serialized form
1298                   * JSON cannot represent therefore resolves to whatever that rebuild encodes to, which is what the
1299                   * client is sent for it as well.
1300                   *
1301                   * A throwing JsonSerializable::jsonSerialize() is caught for the same reason the value is checked
1302                   * at all: a binding must not be able to abort the render. An exception escaping here would leave
1303                   * `$context_stack` and `$namespace_stack` unrestored for every later `process_directives()` call
1304                   * on this instance, so the object is treated as one which failed to encode.
1305                   */
1306                  if ( is_object( $result ) ) {
1307                      try {
1308                          $encoded = wp_json_encode( $result );
1309                      } catch ( Throwable $e ) {
1310                          $encoded = false;
1311                      }
1312                      if ( false !== $encoded ) {
1313                          $result = json_decode( $encoded );
1314                      }
1315                  }
1316  
1317                  /*
1318                   * Only a value which can be sent to the client may be stored in an attribute value. Strings and
1319                   * booleans are passed in as-is, numbers are formatted, and everything else is rejected as a usage
1320                   * error.
1321                   *
1322                   * An object which does not serialize to a scalar is rejected even when it defines `__toString()`,
1323                   * which PHP would otherwise coerce for the string parameters of the escaping functions. Its string
1324                   * representation is not what the client evaluates this reference to, whether that is the form
1325                   * serialized into the store or the return value of a derived state's client-side implementation,
1326                   * so the two could disagree once the directive is evaluated during hydration.
1327                   */
1328                  if ( null !== $result ) {
1329                      if ( ! is_scalar( $result ) ) {
1330                          _doing_it_wrong(
1331                              __METHOD__,
1332                              sprintf(
1333                                  /* translators: %s: The attribute name. */
1334                                  __( 'Attempted to bind a non-scalar value to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a string, number, or boolean.' ),
1335                                  esc_html( $entry['suffix'] )
1336                              ),
1337                              '7.1.0'
1338                          );
1339                          $result = null;
1340                      } elseif ( is_int( $result ) || is_float( $result ) ) {
1341                          /*
1342                           * A number is formatted by the JSON encoder rather than cast to string, so that the
1343                           * attribute value matches the number the client receives for this same reference. Casting
1344                           * a float is locale-dependent before PHP 8.0, and rounds to `precision` rather than to the
1345                           * encoder's `serialize_precision`.
1346                           *
1347                           * This closes the cases which differ in practice, not every one. A float written in
1348                           * exponent notation still disagrees, since PHP encodes 1e25 as `1.0e+25` where JavaScript
1349                           * renders it as `1e+25`, as does negative zero, and an integer above the range JavaScript
1350                           * can represent exactly is rounded once it reaches the client. Casting diverged on all
1351                           * three as well, so none is a regression.
1352                           */
1353                          $encoded = wp_json_encode( $result );
1354                          if ( JSON_ERROR_INF_OR_NAN === json_last_error() ) {
1355                              /*
1356                               * The encoder only rejects INF and NAN, of which JSON can represent neither. When such
1357                               * a value is stored in state, the store itself also fails to encode in its entirety,
1358                               * and the client is sent an empty script tag in place of all of its state; only
1359                               * removing the value from the state resolves that. A derived state closure returning
1360                               * one never reaches the store, so there only the binding itself is affected.
1361                               */
1362                              _doing_it_wrong(
1363                                  __METHOD__,
1364                                  sprintf(
1365                                      /* translators: %s: The attribute name. */
1366                                      __( 'Attempted to bind a non-finite number to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a finite number or a string.' ),
1367                                      esc_html( $entry['suffix'] )
1368                                  ),
1369                                  '7.1.0'
1370                              );
1371                              $result = null;
1372                          } else {
1373                              $result = $encoded;
1374                          }
1375                      }
1376                  }
1377  
1378                  if (
1379                      null !== $result &&
1380                      (
1381                          false !== $result ||
1382                          ( strlen( $entry['suffix'] ) > 5 && '-' === $entry['suffix'][4] )
1383                      )
1384                  ) {
1385                      /*
1386                       * If the result of the evaluation is a boolean and the attribute is
1387                       * `aria-` or `data-, convert it to a string "true" or "false". It
1388                       * follows the exact same logic as Preact because it needs to
1389                       * replicate what Preact will later do in the client:
1390                       * https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136
1391                       */
1392                      if (
1393                          is_bool( $result ) &&
1394                          ( strlen( $entry['suffix'] ) > 5 && '-' === $entry['suffix'][4] )
1395                      ) {
1396                          $result = $result ? 'true' : 'false';
1397                      }
1398  
1399                      $p->set_attribute( $entry['suffix'], $result );
1400                  } else {
1401                      $p->remove_attribute( $entry['suffix'] );
1402                  }
1403              }
1404          }
1405      }
1406  
1407      /**
1408       * Determines whether a binding may set or remove an attribute of an `object` element.
1409       *
1410       * KSES only allows an `object` element in post content after checking the values of its
1411       * `data` and `type` attributes. Neither the server nor the client can repeat that check for
1412       * a bound value, so a binding on an `object` element may only change attributes that cannot
1413       * change what it loads: `aria-*` attributes, `data-*` attributes other than directives,
1414       * `width`, `height`, and the global attributes KSES allows on any element. Any `aria-*`
1415       * attribute is allowed, where KSES lists only some. A `data-wp-*` attribute is refused
1416       * because it is a directive, which could bind `data` or `type` once rendered. The client
1417       * runtime enforces the same set.
1418       *
1419       * @since 7.1.4
1420       *
1421       * @param string $attribute_name The name of the bound attribute.
1422       * @return bool Whether the attribute may be bound on an `object` element.
1423       */
1424  	private static function is_bindable_object_attribute( string $attribute_name ): bool {
1425          $attribute_name = strtolower( $attribute_name );
1426  
1427          return (
1428              str_starts_with( $attribute_name, 'aria-' ) ||
1429              ( str_starts_with( $attribute_name, 'data-' ) && ! str_starts_with( $attribute_name, 'data-wp-' ) ) ||
1430              in_array( $attribute_name, array( 'class', 'dir', 'height', 'hidden', 'id', 'lang', 'role', 'style', 'tabindex', 'title', 'width' ), true )
1431          );
1432      }
1433  
1434      /**
1435       * Processes the `data-wp-class` directive.
1436       *
1437       * It adds or removes CSS classes in the current HTML element based on the
1438       * evaluation of its associated references.
1439       *
1440       * @since 6.5.0
1441       *
1442       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
1443       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
1444       */
1445  	private function data_wp_class_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
1446          if ( 'enter' === $mode ) {
1447              $entries = $this->get_directive_entries( $p, 'class' );
1448              foreach ( $entries as $entry ) {
1449                  if ( empty( $entry['suffix'] ) ) {
1450                      continue;
1451                  }
1452                  $class_name = isset( $entry['unique_id'] ) && $entry['unique_id']
1453                      ? "{$entry['suffix']}---{$entry['unique_id']}"
1454                      : $entry['suffix'];
1455  
1456                  if ( empty( $class_name ) ) {
1457                      return;
1458                  }
1459  
1460                  $result = $this->evaluate( $entry );
1461  
1462                  if ( $result ) {
1463                      $p->add_class( $class_name );
1464                  } else {
1465                      $p->remove_class( $class_name );
1466                  }
1467              }
1468          }
1469      }
1470  
1471      /**
1472       * Processes the `data-wp-style` directive.
1473       *
1474       * It updates the style attribute value of the current HTML element based on
1475       * the evaluation of its associated references.
1476       *
1477       * @since 6.5.0
1478       *
1479       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
1480       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
1481       */
1482  	private function data_wp_style_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
1483          if ( 'enter' === $mode ) {
1484              $entries = $this->get_directive_entries( $p, 'style' );
1485              foreach ( $entries as $entry ) {
1486                  $style_property = $entry['suffix'];
1487                  if ( empty( $style_property ) || null !== $entry['unique_id'] ) {
1488                      continue;
1489                  }
1490  
1491                  $style_property_value  = $this->evaluate( $entry );
1492                  $style_attribute_value = $p->get_attribute( 'style' );
1493                  $style_attribute_value = ( $style_attribute_value && ! is_bool( $style_attribute_value ) ) ? $style_attribute_value : '';
1494  
1495                  /*
1496                   * Checks first if the style property is not falsy and the style
1497                   * attribute value is not empty because if it is, it doesn't need to
1498                   * update the attribute value.
1499                   */
1500                  if ( $style_property_value || $style_attribute_value ) {
1501                      $style_attribute_value = $this->merge_style_property( $style_attribute_value, $style_property, $style_property_value );
1502                      /*
1503                       * If the style attribute value is not empty, it sets it. Otherwise,
1504                       * it removes it.
1505                       */
1506                      if ( ! empty( $style_attribute_value ) ) {
1507                          $p->set_attribute( 'style', $style_attribute_value );
1508                      } else {
1509                          $p->remove_attribute( 'style' );
1510                      }
1511                  }
1512              }
1513          }
1514      }
1515  
1516      /**
1517       * Merges an individual style property in the `style` attribute of an HTML
1518       * element, updating or removing the property when necessary.
1519       *
1520       * If a property is modified, the old one is removed and the new one is added
1521       * at the end of the list.
1522       *
1523       * @since 6.5.0
1524       *
1525       * Example:
1526       *
1527       *     merge_style_property( 'color:green;', 'color', 'red' )      => 'color:red;'
1528       *     merge_style_property( 'background:green;', 'color', 'red' ) => 'background:green;color:red;'
1529       *     merge_style_property( 'color:green;', 'color', null )       => ''
1530       *
1531       * @param string            $style_attribute_value The current style attribute value.
1532       * @param string            $style_property_name   The style property name to set.
1533       * @param string|false|null $style_property_value  The value to set for the style property. With false, null or an
1534       *                                                 empty string, it removes the style property.
1535       * @return string The new style attribute value after the specified property has been added, updated or removed.
1536       */
1537  	private function merge_style_property( string $style_attribute_value, string $style_property_name, $style_property_value ): string {
1538          $style_assignments    = explode( ';', $style_attribute_value );
1539          $result               = array();
1540          $style_property_value = ! empty( $style_property_value ) ? rtrim( trim( $style_property_value ), ';' ) : null;
1541          $new_style_property   = $style_property_value ? $style_property_name . ':' . $style_property_value . ';' : '';
1542  
1543          // Generates an array with all the properties but the modified one.
1544          foreach ( $style_assignments as $style_assignment ) {
1545              if ( empty( trim( $style_assignment ) ) ) {
1546                  continue;
1547              }
1548              list( $name, $value ) = explode( ':', $style_assignment );
1549              if ( trim( $name ) !== $style_property_name ) {
1550                  $result[] = trim( $name ) . ':' . trim( $value ) . ';';
1551              }
1552          }
1553  
1554          // Adds the new/modified property at the end of the list.
1555          $result[] = $new_style_property;
1556  
1557          return implode( '', $result );
1558      }
1559  
1560      /**
1561       * Processes the `data-wp-text` directive.
1562       *
1563       * It updates the inner content of the current HTML element based on the
1564       * evaluation of its associated reference.
1565       *
1566       * @since 6.5.0
1567       *
1568       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
1569       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
1570       */
1571  	private function data_wp_text_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
1572          if ( 'enter' === $mode ) {
1573              $entries = $this->get_directive_entries( $p, 'text' );
1574  
1575              // Get the first valid `data-wp-text` entry without suffix or unique ID.
1576              $valid_entry = array_find(
1577                  $entries,
1578                  fn( $entry ) => null === $entry['suffix'] && null === $entry['unique_id'] && ! empty( $entry['value'] )
1579              );
1580  
1581              if ( null === $valid_entry ) {
1582                  return;
1583              }
1584  
1585              $result = $this->evaluate( $valid_entry );
1586  
1587              /*
1588               * Follows the same logic as Preact in the client and only changes the
1589               * content if the value is a string or a number. Otherwise, it removes the
1590               * content.
1591               */
1592              if ( is_string( $result ) || is_numeric( $result ) ) {
1593                  $p->set_content_between_balanced_tags( esc_html( $result ) );
1594              } else {
1595                  $p->set_content_between_balanced_tags( '' );
1596              }
1597          }
1598      }
1599  
1600      /**
1601       * Processes the `data-wp-html` directive.
1602       *
1603       * Only an entry without a suffix or a unique ID controls the content: the
1604       * HTML of the token its reference resolves to, written as given, or nothing
1605       * for any other value. No directive inside that content is processed. See
1606       * {@see wp_interactivity_as_dangerous_html()} for the risks of marking HTML
1607       * as trusted.
1608       *
1609       * HTML that closes the element, or that the browser moves out of it, such
1610       * as a `div` in a `p`, is still written as given. The browser places the part
1611       * that escapes after the element, where the client hydrates it as ordinary
1612       * markup although the server did not process its directives.
1613       *
1614       * A value other than a token or `null` raises a notice. A reference that
1615       * resolves to nothing does not.
1616       *
1617       * The element is processed as if it had no `data-wp-html` directive when it
1618       * also carries `data-wp-text`, `data-wp-each` or `data-wp-router-region`, which
1619       * raises a notice, or when its entries all have a suffix or unique ID or are
1620       * dropped by the client. The directive is also ignored, with a notice, on void
1621       * elements and on `script`, `style`, `textarea`, `title`, `iframe`, `xmp`,
1622       * `noembed` and `noframes` elements. Their content is preserved and the
1623       * reference is not evaluated.
1624       *
1625       * @since 7.2.0
1626       *
1627       * @param WP_Interactivity_API_Directives_Processor $p    The directives processor instance.
1628       * @param string                                    $mode Whether the processing is entering or exiting the tag.
1629       */
1630  	private function data_wp_html_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ): void {
1631          if ( 'enter' !== $mode ) {
1632              return;
1633          }
1634  
1635          $directives = $this->get_client_directives( $p );
1636          if ( empty( $directives['html'] ) ) {
1637              return;
1638          }
1639  
1640          foreach ( array( 'text', 'each', 'router-region' ) as $conflicting_prefix ) {
1641              if ( ! empty( $directives[ $conflicting_prefix ] ) ) {
1642                  _doing_it_wrong(
1643                      __METHOD__,
1644                      sprintf(
1645                          /* translators: %s: The directive combined with data-wp-html, such as data-wp-text. */
1646                          __( 'The "data-wp-html" directive cannot be combined with the "%s" directive on the same element. The "data-wp-html" directive is ignored.' ),
1647                          esc_html( 'data-wp-' . $conflicting_prefix )
1648                      ),
1649                      '7.2.0'
1650                  );
1651                  return;
1652              }
1653          }
1654  
1655          $host_entry = array_find(
1656              $directives['html'],
1657              fn( $entry ) => null === $entry['suffix'] && null === $entry['unique_id']
1658          );
1659          if ( null === $host_entry ) {
1660              return;
1661          }
1662  
1663          if ( ! $p->has_and_visits_its_closer_tag() ) {
1664              _doing_it_wrong(
1665                  __METHOD__,
1666                  sprintf(
1667                      /* translators: %s: The HTML tag name of the element, in lowercase. */
1668                      __( 'The "data-wp-html" directive is not supported on <%s> elements. The directive is ignored.' ),
1669                      esc_html( strtolower( $p->get_tag() ) )
1670                  ),
1671                  '7.2.0'
1672              );
1673              return;
1674          }
1675  
1676          $result = $this->resolve_html_reference( $host_entry['value'] );
1677          $html   = WP_Interactivity_Dangerous_HTML::get_html( $result );
1678  
1679          if ( null === $html && null !== $result ) {
1680              _doing_it_wrong(
1681                  __METHOD__,
1682                  __( '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 content of the element is removed.' ),
1683                  '7.2.0'
1684              );
1685          }
1686  
1687          if ( null === $html ) {
1688              $p->skip_to_balanced_tag_closer( '' );
1689              return;
1690          }
1691  
1692          /*
1693           * The HTML is held back and a placeholder comment takes its place, so no
1694           * later step of the call scans it. A comment is a single token for the
1695           * processors. Its marker holds a random UUID, drawn once per call by the
1696           * first host that renders, so no text written before the call can
1697           * realistically contain a placeholder.
1698           */
1699          if ( '' === $this->html_placeholder_marker ) {
1700              $this->html_placeholder_marker = 'wp-interactivity-dangerous-html-' . wp_generate_uuid4();
1701          }
1702          $placeholder = '<!--' . $this->html_placeholder_marker . ':' . count( $this->html_placeholders ) . '-->';
1703          if ( $p->skip_to_balanced_tag_closer( $placeholder ) ) {
1704              $this->html_placeholders[ $placeholder ] = $html;
1705          }
1706      }
1707  
1708      /**
1709       * Gets the directives on the current element that the client keeps.
1710       *
1711       * The client drops a directive when a reserved word (`__proto__`,
1712       * `constructor` or `prototype`) is its prefix, any `--`-separated part of its
1713       * suffix or its unique ID, ignoring case and leading and trailing dashes, or
1714       * when its value names a reserved word as its namespace.
1715       *
1716       * @since 7.2.0
1717       *
1718       * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
1719       * @return array[] The directives by prefix, such as `html`, each in the order of the element's attributes,
1720       *                 with their raw values.
1721       * @phpstan-return array<non-empty-string, list<array{
1722       *     suffix: non-empty-string|null,
1723       *     unique_id: non-empty-string|null,
1724       *     value: string|true,
1725       * }>>
1726       */
1727  	private function get_client_directives( WP_Interactivity_API_Directives_Processor $p ): array {
1728          $directives = array();
1729          foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) ?? array() as $attribute_name ) {
1730              $parsed_directive = $this->parse_directive_name( $attribute_name );
1731              if ( null === $parsed_directive ) {
1732                  continue;
1733              }
1734  
1735              $name_parts = array( $parsed_directive['prefix'] );
1736              if ( null !== $parsed_directive['suffix'] ) {
1737                  array_push( $name_parts, ...explode( '--', $parsed_directive['suffix'] ) );
1738              }
1739              if ( null !== $parsed_directive['unique_id'] ) {
1740                  $name_parts[] = $parsed_directive['unique_id'];
1741              }
1742              foreach ( $name_parts as $name_part ) {
1743                  if ( self::is_reserved_word( strtolower( trim( $name_part, '-' ) ) ) ) {
1744                      continue 2;
1745                  }
1746              }
1747  
1748              $value = $p->get_attribute( $attribute_name );
1749              if ( is_string( $value ) && 1 === preg_match( self::NAMESPACED_VALUE_REGEX, $value, $matches ) && self::is_reserved_word( $matches[1] ) ) {
1750                  continue;
1751              }
1752  
1753              $directives[ $parsed_directive['prefix'] ][] = array(
1754                  'suffix'    => $parsed_directive['suffix'],
1755                  'unique_id' => $parsed_directive['unique_id'],
1756                  'value'     => $value,
1757              );
1758          }
1759          return $directives;
1760      }
1761  
1762      /**
1763       * Resolves the reference of a `data-wp-html` directive as the client does.
1764       *
1765       * The reference is the raw attribute value, never decoded as JSON: an
1766       * optional `<namespace>::`, an optional `!` and a dot-separated path.
1767       * Without `<namespace>::`, the namespace is the one that the nearest
1768       * `data-wp-interactive` directive names, never one inherited from a further
1769       * one. When it names none, the reference resolves to `null`. It also
1770       * resolves to `null`, without being evaluated, when its path is empty or
1771       * starts with a second `!`, which names no property, or when a segment of
1772       * its path is a reserved word (`__proto__`, `constructor` or `prototype`).
1773       * The `!` applies after that, so `!state.x.constructor` resolves to `true`.
1774       *
1775       * @since 7.2.0
1776       *
1777       * @param string|true $reference The attribute value. It is `true` when the attribute has no value.
1778       * @return mixed The value the reference resolves to, or `null` if it resolves to nothing.
1779       */
1780  	private function resolve_html_reference( $reference ) {
1781          if ( ! is_string( $reference ) ) {
1782              return null;
1783          }
1784  
1785          if ( 1 === preg_match( self::NAMESPACED_VALUE_REGEX, $reference, $matches ) ) {
1786              list( , $namespace, $path ) = $matches;
1787          } else {
1788              $namespace = array_last( $this->island_namespace_stack ?? array() );
1789              $path      = $reference;
1790          }
1791  
1792          $should_negate_value = false;
1793          if ( str_starts_with( $path, '!' ) ) {
1794              $path                = substr( $path, 1 );
1795              $should_negate_value = '' !== $path;
1796          }
1797  
1798          if (
1799              '' === $path ||
1800              str_starts_with( $path, '!' ) ||
1801              null !== array_find( explode( '.', $path ), fn( $segment ) => self::is_reserved_word( $segment ) )
1802          ) {
1803              $value = null;
1804          } else {
1805              $value = $this->evaluate(
1806                  array(
1807                      'namespace' => $namespace,
1808                      'value'     => $path,
1809                  )
1810              );
1811          }
1812  
1813          return $should_negate_value ? ! $value : $value;
1814      }
1815  
1816      /**
1817       * Checks whether a name is one the client never resolves or keeps as a
1818       * directive name part, because it would reach the prototype of an object.
1819       *
1820       * @since 7.2.0
1821       *
1822       * @param string $name The name.
1823       * @return bool Whether the name is `__proto__`, `constructor` or `prototype`.
1824       */
1825  	private static function is_reserved_word( string $name ): bool {
1826          return '__proto__' === $name || 'constructor' === $name || 'prototype' === $name;
1827      }
1828  
1829      /**
1830       * Returns the CSS styles for animating the top loading bar in the router.
1831       *
1832       * @since 6.5.0
1833       *
1834       * @return string The CSS styles for the router's top loading bar animation.
1835       */
1836  	private function get_router_animation_styles(): string {
1837          return <<<CSS
1838              .wp-interactivity-router-loading-bar {
1839                  position: fixed;
1840                  top: 0;
1841                  left: 0;
1842                  margin: 0;
1843                  padding: 0;
1844                  width: 100vw;
1845                  max-width: 100vw !important;
1846                  height: 4px;
1847                  background-color: #000;
1848                  opacity: 0
1849              }
1850              .wp-interactivity-router-loading-bar.start-animation {
1851                  animation: wp-interactivity-router-loading-bar-start-animation 30s cubic-bezier(0.03, 0.5, 0, 1) forwards
1852              }
1853              .wp-interactivity-router-loading-bar.finish-animation {
1854                  animation: wp-interactivity-router-loading-bar-finish-animation 300ms ease-in
1855              }
1856              @keyframes wp-interactivity-router-loading-bar-start-animation {
1857                  0% { transform: scaleX(0); transform-origin: 0 0; opacity: 1 }
1858                  100% { transform: scaleX(1); transform-origin: 0 0; opacity: 1 }
1859              }
1860              @keyframes wp-interactivity-router-loading-bar-finish-animation {
1861                  0% { opacity: 1 }
1862                  50% { opacity: 1 }
1863                  100% { opacity: 0 }
1864              }
1865  CSS;
1866      }
1867  
1868      /**
1869       * Deprecated.
1870       *
1871       * @since 6.5.0
1872       * @deprecated 6.7.0 Use {@see WP_Interactivity_API::print_router_markup} instead.
1873       */
1874  	public function print_router_loading_and_screen_reader_markup() {
1875          _deprecated_function( __METHOD__, '6.7.0', 'WP_Interactivity_API::print_router_markup' );
1876  
1877          // Call the new method.
1878          $this->print_router_markup();
1879      }
1880  
1881      /**
1882       * Outputs markup for the @wordpress/interactivity-router script module.
1883       *
1884       * This method prints a div element representing a loading bar visible during
1885       * navigation.
1886       *
1887       * @since 6.7.0
1888       */
1889  	public function print_router_markup() {
1890          echo <<<HTML
1891              <div
1892                  class="wp-interactivity-router-loading-bar"
1893                  data-wp-interactive="core/router/private"
1894                  data-wp-class--start-animation="state.navigation.hasStarted"
1895                  data-wp-class--finish-animation="state.navigation.hasFinished"
1896              ></div>
1897  HTML;
1898      }
1899  
1900      /**
1901       * Processes the `data-wp-router-region` directive.
1902       *
1903       * It renders in the footer a set of HTML elements to notify users about
1904       * client-side navigations. More concretely, the elements added are 1) a
1905       * top loading bar to visually inform that a navigation is in progress
1906       * and 2) an `aria-live` region for accessible navigation announcements.
1907       *
1908       * @since 6.5.0
1909       *
1910       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
1911       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
1912       */
1913  	private function data_wp_router_region_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
1914          if ( 'enter' === $mode && ! $this->has_processed_router_region ) {
1915              $this->has_processed_router_region = true;
1916  
1917              // Initializes the `state.url` property from the server.
1918              $this->state(
1919                  'core/router',
1920                  array(
1921                      'url' => get_self_link(),
1922                  )
1923              );
1924  
1925              // Enqueues as an inline style.
1926              wp_register_style( 'wp-interactivity-router-animations', false );
1927              wp_add_inline_style( 'wp-interactivity-router-animations', $this->get_router_animation_styles() );
1928              wp_enqueue_style( 'wp-interactivity-router-animations' );
1929  
1930              // Adds the necessary markup to the footer.
1931              add_action( 'wp_footer', array( $this, 'print_router_markup' ) );
1932          }
1933      }
1934  
1935      /**
1936       * Processes the `data-wp-each` directive.
1937       *
1938       * This directive gets an array passed as reference and iterates over it
1939       * generating new content for each item based on the inner markup of the
1940       * `template` tag.
1941       *
1942       * @since 6.5.0
1943       * @since 6.9.0 Include the list path in the rendered `data-wp-each-child` directives.
1944       *
1945       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
1946       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
1947       * @param array                                     $tag_stack       The reference to the tag stack.
1948       */
1949  	private function data_wp_each_processor( WP_Interactivity_API_Directives_Processor $p, string $mode, array &$tag_stack ) {
1950          if ( 'enter' === $mode && 'TEMPLATE' === $p->get_tag() ) {
1951              $entries = $this->get_directive_entries( $p, 'each' );
1952              if ( count( $entries ) > 1 || empty( $entries ) ) {
1953                  // There should be only one `data-wp-each` directive per template tag.
1954                  return;
1955              }
1956              $entry = $entries[0];
1957              if ( null !== $entry['unique_id'] ) {
1958                  return;
1959              }
1960              $item_name = isset( $entry['suffix'] ) ? $this->kebab_to_camel_case( $entry['suffix'] ) : 'item';
1961              $result    = $this->evaluate( $entry );
1962  
1963              // Gets the content between the template tags and leaves the cursor in the closer tag.
1964              $inner_content = $p->get_content_between_balanced_template_tags();
1965  
1966              // Checks if there is a manual server-side directive processing.
1967              $template_end = 'data-wp-each: template end';
1968              $p->set_bookmark( $template_end );
1969              $p->next_tag();
1970              $manual_sdp = $p->get_attribute( 'data-wp-each-child' );
1971              $p->seek( $template_end ); // Rewinds to the template closer tag.
1972              $p->release_bookmark( $template_end );
1973  
1974              /*
1975               * It doesn't process in these situations:
1976               * - Manual server-side directive processing.
1977               * - Empty or non-array values.
1978               * - Associative arrays because those are deserialized as objects in JS.
1979               * - Templates that contain top-level texts because those texts can't be
1980               *   identified and removed in the client.
1981               */
1982              if (
1983                  $manual_sdp ||
1984                  empty( $result ) ||
1985                  ! is_array( $result ) ||
1986                  ! array_is_list( $result ) ||
1987                  ! str_starts_with( trim( $inner_content ), '<' ) ||
1988                  ! str_ends_with( trim( $inner_content ), '>' )
1989              ) {
1990                  array_pop( $tag_stack );
1991                  return;
1992              }
1993  
1994              // Processes the inner content for each item of the array.
1995              $processed_content = '';
1996              foreach ( $result as $item ) {
1997                  // Creates a new context that includes the current item of the array.
1998                  $this->context_stack[] = array_replace_recursive(
1999                      end( $this->context_stack ) !== false ? end( $this->context_stack ) : array(),
2000                      array( $entry['namespace'] => array( $item_name => $item ) )
2001                  );
2002  
2003                  // Processes the inner content with the new context.
2004                  $processed_item = $this->_process_directives( $inner_content );
2005  
2006                  if ( null === $processed_item ) {
2007                      // If the HTML is unbalanced, stop processing it.
2008                      array_pop( $this->context_stack );
2009                      return;
2010                  }
2011  
2012                  /*
2013                   * Adds the `data-wp-each-child` directive to each top-level tag
2014                   * rendered by this `data-wp-each` directive. The value is the
2015                   * `data-wp-each` directive's namespace and path.
2016                   *
2017                   * Nested `data-wp-each` directives could render
2018                   * `data-wp-each-child` elements at the top level as well, and
2019                   * they should be overwritten.
2020                   *
2021                   * @since 6.9.0
2022                   */
2023                  $i = new WP_Interactivity_API_Directives_Processor( $processed_item );
2024                  while ( $i->next_tag() ) {
2025                      $i->set_attribute( 'data-wp-each-child', $entry['namespace'] . '::' . $entry['value'] );
2026                      $i->next_balanced_tag_closer_tag();
2027                  }
2028                  $processed_content .= $i->get_updated_html();
2029  
2030                  // Removes the current context from the stack.
2031                  array_pop( $this->context_stack );
2032              }
2033  
2034              // Appends the processed content after the tag closer of the template.
2035              $p->append_content_after_template_tag_closer( $processed_content );
2036  
2037              // Pops the last tag because it skipped the closing tag of the template tag.
2038              array_pop( $tag_stack );
2039          }
2040      }
2041  }


Generated : Thu Nov 12 08:20:02 2026 Cross-referenced by PHPXref