[ 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
  21       */
  22      private static $directive_processors = array(
  23          'data-wp-interactive'   => 'data_wp_interactive_processor',
  24          'data-wp-router-region' => 'data_wp_router_region_processor',
  25          'data-wp-context'       => 'data_wp_context_processor',
  26          'data-wp-bind'          => 'data_wp_bind_processor',
  27          'data-wp-class'         => 'data_wp_class_processor',
  28          'data-wp-style'         => 'data_wp_style_processor',
  29          'data-wp-text'          => 'data_wp_text_processor',
  30          /*
  31           * `data-wp-each` needs to be processed in the last place because it moves
  32           * the cursor to the end of the processed items to prevent them to be
  33           * processed twice.
  34           */
  35          'data-wp-each'          => 'data_wp_each_processor',
  36      );
  37  
  38      /**
  39       * Holds the initial state of the different Interactivity API stores.
  40       *
  41       * This state is used during the server directive processing. Then, it is
  42       * serialized and sent to the client as part of the interactivity data to be
  43       * recovered during the hydration of the client interactivity stores.
  44       *
  45       * @since 6.5.0
  46       * @var array
  47       */
  48      private $state_data = array();
  49  
  50      /**
  51       * Holds the configuration required by the different Interactivity API stores.
  52       *
  53       * This configuration is serialized and sent to the client as part of the
  54       * interactivity data and can be accessed by the client interactivity stores.
  55       *
  56       * @since 6.5.0
  57       * @var array
  58       */
  59      private $config_data = array();
  60  
  61      /**
  62       * Keeps track of all derived state closures accessed during server-side rendering.
  63       *
  64       * This data is serialized and sent to the client as part of the interactivity
  65       * data, and is handled later in the client to support derived state props that
  66       * are lazily hydrated.
  67       *
  68       * @since 6.9.0
  69       * @var array
  70       */
  71      private $derived_state_closures = array();
  72  
  73      /**
  74       * Flag that indicates whether the `data-wp-router-region` directive has
  75       * been found in the HTML and processed.
  76       *
  77       * The value is saved in a private property of the WP_Interactivity_API
  78       * instance instead of using a static variable inside the processor
  79       * function, which would hold the same value for all instances
  80       * independently of whether they have processed any
  81       * `data-wp-router-region` directive or not.
  82       *
  83       * @since 6.5.0
  84       * @var bool
  85       */
  86      private $has_processed_router_region = false;
  87  
  88      /**
  89       * Set of script modules that can be loaded after client-side navigation.
  90       *
  91       * @since 6.9.0
  92       * @var array<string, true>
  93       */
  94      private $script_modules_that_can_load_on_client_navigation = array();
  95  
  96      /**
  97       * Stack of namespaces defined by `data-wp-interactive` directives, in
  98       * the order they are processed.
  99       *
 100       * This is only available during directive processing, otherwise it is `null`.
 101       *
 102       * @since 6.6.0
 103       * @var array<string>|null
 104       */
 105      private $namespace_stack = null;
 106  
 107      /**
 108       * Stack of contexts defined by `data-wp-context` directives, in
 109       * the order they are processed.
 110       *
 111       * This is only available during directive processing, otherwise it is `null`.
 112       *
 113       * @since 6.6.0
 114       * @var array<array<mixed>>|null
 115       */
 116      private $context_stack = null;
 117  
 118      /**
 119       * Representation in array format of the element currently being processed.
 120       *
 121       * This is only available during directive processing, otherwise it is `null`.
 122       *
 123       * @since 6.7.0
 124       * @var array{attributes: array<string, string|bool>}|null
 125       */
 126      private $current_element = null;
 127  
 128      /**
 129       * Gets and/or sets the initial state of an Interactivity API store for a
 130       * given namespace.
 131       *
 132       * If state for that store namespace already exists, it merges the new
 133       * provided state with the existing one.
 134       *
 135       * When no namespace is specified, it returns the state defined for the
 136       * current value in the internal namespace stack during a `process_directives` call.
 137       *
 138       * @since 6.5.0
 139       * @since 6.6.0 The `$store_namespace` param is optional.
 140       *
 141       * @param string $store_namespace Optional. The unique store namespace identifier.
 142       * @param array  $state           Optional. The array that will be merged with the existing state for the specified
 143       *                                store namespace.
 144       * @return array The current state for the specified store namespace. This will be the updated state if a $state
 145       *               argument was provided.
 146       */
 147  	public function state( ?string $store_namespace = null, ?array $state = null ): array {
 148          if ( ! $store_namespace ) {
 149              if ( $state ) {
 150                  _doing_it_wrong(
 151                      __METHOD__,
 152                      __( 'The namespace is required when state data is passed.' ),
 153                      '6.6.0'
 154                  );
 155                  return array();
 156              }
 157              if ( null !== $store_namespace ) {
 158                  _doing_it_wrong(
 159                      __METHOD__,
 160                      __( 'The namespace should be a non-empty string.' ),
 161                      '6.6.0'
 162                  );
 163                  return array();
 164              }
 165              if ( null === $this->namespace_stack ) {
 166                  _doing_it_wrong(
 167                      __METHOD__,
 168                      __( 'The namespace can only be omitted during directive processing.' ),
 169                      '6.6.0'
 170                  );
 171                  return array();
 172              }
 173  
 174              $store_namespace = end( $this->namespace_stack );
 175          }
 176          if ( ! isset( $this->state_data[ $store_namespace ] ) ) {
 177              $this->state_data[ $store_namespace ] = array();
 178          }
 179          if ( is_array( $state ) ) {
 180              $this->state_data[ $store_namespace ] = array_replace_recursive(
 181                  $this->state_data[ $store_namespace ],
 182                  $state
 183              );
 184          }
 185          return $this->state_data[ $store_namespace ];
 186      }
 187  
 188      /**
 189       * Gets and/or sets the configuration of the Interactivity API for a given
 190       * store namespace.
 191       *
 192       * If configuration for that store namespace exists, it merges the new
 193       * provided configuration with the existing one.
 194       *
 195       * @since 6.5.0
 196       *
 197       * @param string $store_namespace The unique store namespace identifier.
 198       * @param array  $config          Optional. The array that will be merged with the existing configuration for the
 199       *                                specified store namespace.
 200       * @return array The configuration for the specified store namespace. This will be the updated configuration if a
 201       *               $config argument was provided.
 202       */
 203  	public function config( string $store_namespace, array $config = array() ): array {
 204          if ( ! isset( $this->config_data[ $store_namespace ] ) ) {
 205              $this->config_data[ $store_namespace ] = array();
 206          }
 207          if ( is_array( $config ) ) {
 208              $this->config_data[ $store_namespace ] = array_replace_recursive(
 209                  $this->config_data[ $store_namespace ],
 210                  $config
 211              );
 212          }
 213          return $this->config_data[ $store_namespace ];
 214      }
 215  
 216      /**
 217       * Prints the serialized client-side interactivity data.
 218       *
 219       * Encodes the config and initial state into JSON and prints them inside a
 220       * script tag of type "application/json". Once in the browser, the state will
 221       * be parsed and used to hydrate the client-side interactivity stores and the
 222       * configuration will be available using a `getConfig` utility.
 223       *
 224       * @since 6.5.0
 225       *
 226       * @deprecated 6.7.0 Client data passing is handled by the {@see "script_module_data_{$module_id}"} filter.
 227       */
 228  	public function print_client_interactivity_data() {
 229          _deprecated_function( __METHOD__, '6.7.0' );
 230      }
 231  
 232      /**
 233       * Set client-side interactivity-router data.
 234       *
 235       * Once in the browser, the state will be parsed and used to hydrate the client-side
 236       * interactivity stores and the configuration will be available using a `getConfig` utility.
 237       *
 238       * @since 6.7.0
 239       *
 240       * @param array $data Data to filter.
 241       * @return array Data for the Interactivity Router script module.
 242       */
 243      public function filter_script_module_interactivity_router_data( array $data ): array {
 244          if ( ! isset( $data['i18n'] ) ) {
 245              $data['i18n'] = array();
 246          }
 247          $data['i18n']['loading'] = __( 'Loading page, please wait.' );
 248          $data['i18n']['loaded']  = __( 'Page Loaded.' );
 249          return $data;
 250      }
 251  
 252      /**
 253       * Set client-side interactivity data.
 254       *
 255       * Once in the browser, the state will be parsed and used to hydrate the client-side
 256       * interactivity stores and the configuration will be available using a `getConfig` utility.
 257       *
 258       * @since 6.7.0
 259       * @since 6.9.0 Serializes derived state props accessed during directive processing.
 260       *
 261       * @param array $data Data to filter.
 262       * @return array Data for the Interactivity API script module.
 263       */
 264  	public function filter_script_module_interactivity_data( array $data ): array {
 265          if (
 266              empty( $this->state_data ) &&
 267              empty( $this->config_data ) &&
 268              empty( $this->derived_state_closures )
 269          ) {
 270              return $data;
 271          }
 272  
 273          $config = array();
 274          foreach ( $this->config_data as $key => $value ) {
 275              if ( ! empty( $value ) ) {
 276                  $config[ $key ] = $value;
 277              }
 278          }
 279          if ( ! empty( $config ) ) {
 280              $data['config'] = $config;
 281          }
 282  
 283          $state = array();
 284          foreach ( $this->state_data as $key => $value ) {
 285              if ( ! empty( $value ) ) {
 286                  $state[ $key ] = $value;
 287              }
 288          }
 289          if ( ! empty( $state ) ) {
 290              $data['state'] = $state;
 291          }
 292  
 293          $derived_props = array();
 294          foreach ( $this->derived_state_closures as $key => $value ) {
 295              if ( ! empty( $value ) ) {
 296                  $derived_props[ $key ] = $value;
 297              }
 298          }
 299          if ( ! empty( $derived_props ) ) {
 300              $data['derivedStateClosures'] = $derived_props;
 301          }
 302  
 303          return $data;
 304      }
 305  
 306      /**
 307       * Returns the latest value on the context stack with the passed namespace.
 308       *
 309       * When the namespace is omitted, it uses the current namespace on the
 310       * namespace stack during a `process_directives` call.
 311       *
 312       * @since 6.6.0
 313       *
 314       * @param string $store_namespace Optional. The unique store namespace identifier.
 315       */
 316  	public function get_context( ?string $store_namespace = null ): array {
 317          if ( null === $this->context_stack ) {
 318              _doing_it_wrong(
 319                  __METHOD__,
 320                  __( 'The context can only be read during directive processing.' ),
 321                  '6.6.0'
 322              );
 323              return array();
 324          }
 325  
 326          if ( ! $store_namespace ) {
 327              if ( null !== $store_namespace ) {
 328                  _doing_it_wrong(
 329                      __METHOD__,
 330                      __( 'The namespace should be a non-empty string.' ),
 331                      '6.6.0'
 332                  );
 333                  return array();
 334              }
 335  
 336              $store_namespace = end( $this->namespace_stack );
 337          }
 338  
 339          $context = end( $this->context_stack );
 340  
 341          return ( $store_namespace && $context && isset( $context[ $store_namespace ] ) )
 342              ? $context[ $store_namespace ]
 343              : array();
 344      }
 345  
 346      /**
 347       * Returns an array representation of the current element being processed.
 348       *
 349       * The returned array contains a copy of the element attributes.
 350       *
 351       * @since 6.7.0
 352       *
 353       * @return array{attributes: array<string, string|bool>}|null Current element.
 354       */
 355  	public function get_element(): ?array {
 356          if ( null === $this->current_element ) {
 357              _doing_it_wrong(
 358                  __METHOD__,
 359                  __( 'The element can only be read during directive processing.' ),
 360                  '6.7.0'
 361              );
 362          }
 363  
 364          return $this->current_element;
 365      }
 366  
 367      /**
 368       * Registers the `@wordpress/interactivity` script modules.
 369       *
 370       * @deprecated 6.7.0 Script Modules registration is handled by {@see wp_default_script_modules()}.
 371       *
 372       * @since 6.5.0
 373       */
 374  	public function register_script_modules() {
 375          _deprecated_function( __METHOD__, '6.7.0', 'wp_default_script_modules' );
 376      }
 377  
 378      /**
 379       * Adds the necessary hooks for the Interactivity API.
 380       *
 381       * @since 6.5.0
 382       * @since 6.9.0 Adds support for client-side navigation in script modules.
 383       */
 384  	public function add_hooks() {
 385          add_filter( 'script_module_data_@wordpress/interactivity', array( $this, 'filter_script_module_interactivity_data' ) );
 386          add_filter( 'script_module_data_@wordpress/interactivity-router', array( $this, 'filter_script_module_interactivity_router_data' ) );
 387          add_filter( 'wp_script_attributes', array( $this, 'add_load_on_client_navigation_attribute_to_script_modules' ), 10, 1 );
 388      }
 389  
 390      /**
 391       * Adds the `data-wp-router-options` attribute to script modules that
 392       * support client-side navigation.
 393       *
 394       * This method filters the script attributes to include loading instructions
 395       * for the Interactivity API router, indicating which modules can be loaded
 396       * during client-side navigation.
 397       *
 398       * @since 6.9.0
 399       *
 400       * @param array<string, string|true>|mixed $attributes The script tag attributes.
 401       * @return array The modified script tag attributes.
 402       */
 403      public function add_load_on_client_navigation_attribute_to_script_modules( $attributes ) {
 404          if (
 405              is_array( $attributes ) &&
 406              isset( $attributes['type'], $attributes['id'] ) &&
 407              'module' === $attributes['type'] &&
 408              array_key_exists(
 409                  preg_replace( '/-js-module$/', '', $attributes['id'] ),
 410                  $this->script_modules_that_can_load_on_client_navigation
 411              )
 412          ) {
 413              $attributes['data-wp-router-options'] = wp_json_encode( array( 'loadOnClientNavigation' => true ) );
 414          }
 415          return $attributes;
 416      }
 417  
 418      /**
 419       * Marks a script module as compatible with client-side navigation.
 420       *
 421       * This method registers a script module to be loaded during client-side
 422       * navigation in the Interactivity API router. Script modules marked with
 423       * this method will have the `loadOnClientNavigation` option enabled in the
 424       * `data-wp-router-options` directive.
 425       *
 426       * @since 6.9.0
 427       *
 428       * @param string $script_module_id The script module identifier.
 429       */
 430      public function add_client_navigation_support_to_script_module( string $script_module_id ) {
 431          $this->script_modules_that_can_load_on_client_navigation[ $script_module_id ] = true;
 432      }
 433  
 434      /**
 435       * Processes the interactivity directives contained within the HTML content
 436       * and updates the markup accordingly.
 437       *
 438       * @since 6.5.0
 439       *
 440       * @param string $html The HTML content to process.
 441       * @return string The processed HTML content. It returns the original content when the HTML contains unbalanced tags.
 442       */
 443  	public function process_directives( string $html ): string {
 444          if ( ! str_contains( $html, 'data-wp-' ) ) {
 445              return $html;
 446          }
 447  
 448          $this->namespace_stack = array();
 449          $this->context_stack   = array();
 450  
 451          $result = $this->_process_directives( $html );
 452  
 453          $this->namespace_stack = null;
 454          $this->context_stack   = null;
 455  
 456          return null === $result ? $html : $result;
 457      }
 458  
 459      /**
 460       * Processes the interactivity directives contained within the HTML content
 461       * and updates the markup accordingly.
 462       *
 463       * It uses the WP_Interactivity_API instance's context and namespace stacks,
 464       * which are shared between all calls.
 465       *
 466       * This method returns null if the HTML contains unbalanced tags.
 467       *
 468       * @since 6.6.0
 469       *
 470       * @param string $html The HTML content to process.
 471       * @return string|null The processed HTML content. It returns null when the HTML contains unbalanced tags.
 472       */
 473  	private function _process_directives( string $html ) {
 474          $p          = new WP_Interactivity_API_Directives_Processor( $html );
 475          $tag_stack  = array();
 476          $unbalanced = false;
 477  
 478          $directive_processor_prefixes          = array_keys( self::$directive_processors );
 479          $directive_processor_prefixes_reversed = array_reverse( $directive_processor_prefixes );
 480  
 481          /*
 482           * Save the current size for each stack to restore them in case
 483           * the processing finds unbalanced tags.
 484           */
 485          $namespace_stack_size = count( $this->namespace_stack );
 486          $context_stack_size   = count( $this->context_stack );
 487  
 488          while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) {
 489              $tag_name = $p->get_tag();
 490  
 491              /*
 492               * Directives inside SVG and MATH tags are not processed,
 493               * as they are not compatible with the Tag Processor yet.
 494               * We still process the rest of the HTML.
 495               */
 496              if ( 'SVG' === $tag_name || 'MATH' === $tag_name ) {
 497                  if ( $p->get_attribute_names_with_prefix( 'data-wp-' ) ) {
 498                      /* translators: 1: SVG or MATH HTML tag, 2: Namespace of the interactive block. */
 499                      $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 ) );
 500                      _doing_it_wrong( __METHOD__, $message, '6.6.0' );
 501                  }
 502                  $p->skip_to_tag_closer();
 503                  continue;
 504              }
 505  
 506              if ( $p->is_tag_closer() ) {
 507                  list( $opening_tag_name, $directives_prefixes ) = ! empty( $tag_stack ) ? end( $tag_stack ) : array( null, null );
 508  
 509                  if ( 0 === count( $tag_stack ) || $opening_tag_name !== $tag_name ) {
 510  
 511                      /*
 512                       * If the tag stack is empty or the matching opening tag is not the
 513                       * same than the closing tag, it means the HTML is unbalanced and it
 514                       * stops processing it.
 515                       */
 516                      $unbalanced = true;
 517                      break;
 518                  } else {
 519                      // Remove the last tag from the stack.
 520                      array_pop( $tag_stack );
 521                  }
 522              } else {
 523                  $each_child_attrs = $p->get_attribute_names_with_prefix( 'data-wp-each-child' );
 524                  if ( null === $each_child_attrs ) {
 525                      continue;
 526                  }
 527  
 528                  if ( 0 !== count( $each_child_attrs ) ) {
 529                      /*
 530                       * If the tag has a `data-wp-each-child` directive, jump to its closer
 531                       * tag because those tags have already been processed.
 532                       */
 533                      $p->next_balanced_tag_closer_tag();
 534                      continue;
 535                  } else {
 536                      $directives_prefixes = array();
 537  
 538                      // Checks if there is a server directive processor registered for each directive.
 539                      foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) {
 540                          $parsed_directive = $this->parse_directive_name( $attribute_name );
 541                          if ( empty( $parsed_directive ) ) {
 542                              continue;
 543                          }
 544                          $directive_prefix = 'data-wp-' . $parsed_directive['prefix'];
 545                          if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) {
 546                              $directives_prefixes[] = $directive_prefix;
 547                          }
 548                      }
 549  
 550                      /*
 551                       * If this tag will visit its closer tag, it adds it to the tag stack
 552                       * so it can process its closing tag and check for unbalanced tags.
 553                       */
 554                      if ( $p->has_and_visits_its_closer_tag() ) {
 555                          $tag_stack[] = array( $tag_name, $directives_prefixes );
 556                      }
 557                  }
 558              }
 559              /*
 560               * If the matching opener tag didn't have any directives, it can skip the
 561               * processing.
 562               */
 563              if ( 0 === count( $directives_prefixes ) ) {
 564                  continue;
 565              }
 566  
 567              // Directive processing might be different depending on if it is entering the tag or exiting it.
 568              $modes = array(
 569                  'enter' => ! $p->is_tag_closer(),
 570                  'exit'  => $p->is_tag_closer() || ! $p->has_and_visits_its_closer_tag(),
 571              );
 572  
 573              // Get the element attributes to include them in the element representation.
 574              $element_attrs = array();
 575              $attr_names    = $p->get_attribute_names_with_prefix( '' ) ?? array();
 576  
 577              foreach ( $attr_names as $name ) {
 578                  $element_attrs[ $name ] = $p->get_attribute( $name );
 579              }
 580  
 581              // Assign the current element right before running its directive processors.
 582              $this->current_element = array(
 583                  'attributes' => $element_attrs,
 584              );
 585  
 586              foreach ( $modes as $mode => $should_run ) {
 587                  if ( ! $should_run ) {
 588                      continue;
 589                  }
 590  
 591                  /*
 592                   * Sorts the attributes by the order of the `directives_processor` array
 593                   * and checks what directives are present in this element.
 594                   */
 595                  $existing_directives_prefixes = array_intersect(
 596                      'enter' === $mode ? $directive_processor_prefixes : $directive_processor_prefixes_reversed,
 597                      $directives_prefixes
 598                  );
 599                  foreach ( $existing_directives_prefixes as $directive_prefix ) {
 600                      $func = is_array( self::$directive_processors[ $directive_prefix ] )
 601                          ? self::$directive_processors[ $directive_prefix ]
 602                          : array( $this, self::$directive_processors[ $directive_prefix ] );
 603  
 604                      call_user_func_array( $func, array( $p, $mode, &$tag_stack ) );
 605                  }
 606              }
 607  
 608              // Clear the current element.
 609              $this->current_element = null;
 610          }
 611  
 612          if ( $unbalanced ) {
 613              // Reset the namespace and context stacks to their previous values.
 614              array_splice( $this->namespace_stack, $namespace_stack_size );
 615              array_splice( $this->context_stack, $context_stack_size );
 616          }
 617  
 618          /*
 619           * It returns null if the HTML is unbalanced because unbalanced HTML is
 620           * not safe to process. In that case, the Interactivity API runtime will
 621           * update the HTML on the client side during the hydration. It will display
 622           * a notice to the developer in the console to inform them about the issue.
 623           */
 624          if ( $unbalanced || 0 < count( $tag_stack ) ) {
 625              return null;
 626          }
 627  
 628          return $p->get_updated_html();
 629      }
 630  
 631      /**
 632       * Evaluates the reference path passed to a directive based on the current
 633       * store namespace, state and context.
 634       *
 635       * @since 6.5.0
 636       * @since 6.6.0 The function now adds a warning when the namespace is null, falsy, or the directive value is empty.
 637       * @since 6.6.0 Removed `default_namespace` and `context` arguments.
 638       * @since 6.6.0 Add support for derived state.
 639       * @since 6.9.0 Recieve $entry as an argument instead of the directive value string.
 640       *
 641       * @param array $entry An array containing a whole directive entry with its namespace, value, suffix, or unique ID.
 642       * @return mixed|null The result of the evaluation. Null if the reference path doesn't exist or the namespace is falsy.
 643       */
 644  	private function evaluate( $entry ) {
 645          $context                               = end( $this->context_stack );
 646          ['namespace' => $ns, 'value' => $path] = $entry;
 647  
 648          if ( ! $ns || ! $path ) {
 649              /* translators: %s: The directive value referenced. */
 650              $message = sprintf( __( 'Namespace or reference path cannot be empty. Directive value referenced: %s' ), json_encode( $entry ) );
 651              _doing_it_wrong( __METHOD__, $message, '6.6.0' );
 652              return null;
 653          }
 654  
 655          $store = array(
 656              'state'   => $this->state_data[ $ns ] ?? array(),
 657              'context' => $context[ $ns ] ?? array(),
 658          );
 659  
 660          // Checks if the reference path is preceded by a negation operator (!).
 661          $should_negate_value = '!' === $path[0];
 662          $path                = $should_negate_value ? substr( $path, 1 ) : $path;
 663  
 664          // Extracts the value from the store using the reference path.
 665          $path_segments = explode( '.', $path );
 666          $current       = $store;
 667          foreach ( $path_segments as $index => $path_segment ) {
 668              /*
 669               * Special case for numeric arrays and strings. Add length
 670               * property mimicking JavaScript behavior.
 671               *
 672               * @since 6.8.0
 673               */
 674              if ( 'length' === $path_segment ) {
 675                  if ( is_array( $current ) && array_is_list( $current ) ) {
 676                      $current = count( $current );
 677                      break;
 678                  }
 679  
 680                  if ( is_string( $current ) ) {
 681                      /*
 682                       * Differences in encoding between PHP strings and
 683                       * JavaScript mean that it's complicated to calculate
 684                       * the string length JavaScript would see from PHP.
 685                       * `strlen` is a reasonable approximation.
 686                       *
 687                       * Users that desire a more precise length likely have
 688                       * more precise needs than "bytelength" and should
 689                       * implement their own length calculation in derived
 690                       * state taking into account encoding and their desired
 691                       * output (codepoints, graphemes, bytes, etc.).
 692                       */
 693                      $current = strlen( $current );
 694                      break;
 695                  }
 696              }
 697  
 698              if ( ( is_array( $current ) || $current instanceof ArrayAccess ) && isset( $current[ $path_segment ] ) ) {
 699                  $current = $current[ $path_segment ];
 700              } elseif ( is_object( $current ) && isset( $current->$path_segment ) ) {
 701                  $current = $current->$path_segment;
 702              } else {
 703                  $current = null;
 704                  break;
 705              }
 706  
 707              if ( $current instanceof Closure ) {
 708                  /*
 709                   * This state getter's namespace is added to the stack so that
 710                   * `state()` or `get_config()` read that namespace when called
 711                   * without specifying one.
 712                   */
 713                  array_push( $this->namespace_stack, $ns );
 714                  try {
 715                      $current = $current();
 716  
 717                      /*
 718                       * Tracks derived state properties that are accessed during
 719                       * rendering.
 720                       *
 721                       * @since 6.9.0
 722                       */
 723                      $this->derived_state_closures[ $ns ] = $this->derived_state_closures[ $ns ] ?? array();
 724  
 725                      // Builds path for the current property and add it to tracking if not already present.
 726                      $current_path = implode( '.', array_slice( $path_segments, 0, $index + 1 ) );
 727                      if ( ! in_array( $current_path, $this->derived_state_closures[ $ns ], true ) ) {
 728                          $this->derived_state_closures[ $ns ][] = $current_path;
 729                      }
 730                  } catch ( Throwable $e ) {
 731                      _doing_it_wrong(
 732                          __METHOD__,
 733                          sprintf(
 734                              /* translators: 1: Path pointing to an Interactivity API state property, 2: Namespace for an Interactivity API store. */
 735                              __( 'Uncaught error executing a derived state callback with path "%1$s" and namespace "%2$s".' ),
 736                              $path,
 737                              $ns
 738                          ),
 739                          '6.6.0'
 740                      );
 741                      return null;
 742                  } finally {
 743                      // Remove the property's namespace from the stack.
 744                      array_pop( $this->namespace_stack );
 745                  }
 746              }
 747          }
 748  
 749          // Returns the opposite if it contains a negation operator (!).
 750          return $should_negate_value ? ! $current : $current;
 751      }
 752  
 753      /**
 754       * Parse the directive name to extract the following parts:
 755       * - Prefix: The main directive name without "data-wp-".
 756       * - Suffix: An optional suffix used during directive processing, extracted after the first double hyphen "--".
 757       * - Unique ID: An optional unique identifier, extracted after the first triple hyphen "---".
 758       *
 759       * This function has an equivalent version for the client side.
 760       * See `parseDirectiveName` in https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/src/vdom.ts.:
 761       *
 762       * See examples in the function unit tests `test_parse_directive_name`.
 763       *
 764       * @since 6.9.0
 765       *
 766       * @param string $directive_name The directive attribute name.
 767       * @return array An array containing the directive prefix, optional suffix, and optional unique ID.
 768       */
 769  	private function parse_directive_name( string $directive_name ): ?array {
 770          // Remove the first 8 characters (assumes "data-wp-" prefix)
 771          $name = substr( $directive_name, 8 );
 772  
 773          // Check for invalid characters (anything not a-z, 0-9, -, or _)
 774          if ( preg_match( '/[^a-z0-9\-_]/i', $name ) ) {
 775              return null;
 776          }
 777  
 778          // Find the first occurrence of '--' to separate the prefix
 779          $suffix_index = strpos( $name, '--' );
 780  
 781          if ( false === $suffix_index ) {
 782              return array(
 783                  'prefix'    => $name,
 784                  'suffix'    => null,
 785                  'unique_id' => null,
 786              );
 787          }
 788  
 789          $prefix    = substr( $name, 0, $suffix_index );
 790          $remaining = substr( $name, $suffix_index );
 791  
 792          // If remaining starts with '---' but not '----', it's a unique_id
 793          if ( '---' === substr( $remaining, 0, 3 ) && '-' !== ( $remaining[3] ?? '' ) ) {
 794              return array(
 795                  'prefix'    => $prefix,
 796                  'suffix'    => null,
 797                  'unique_id' => '---' !== $remaining ? substr( $remaining, 3 ) : null,
 798              );
 799          }
 800  
 801          // Otherwise, remove the first two dashes for a potential suffix
 802          $suffix = substr( $remaining, 2 );
 803  
 804          // Look for '---' in the suffix for a unique_id
 805          $unique_id_index = strpos( $suffix, '---' );
 806  
 807          if ( false !== $unique_id_index && '-' !== ( $suffix[ $unique_id_index + 3 ] ?? '' ) ) {
 808              $unique_id = substr( $suffix, $unique_id_index + 3 );
 809              $suffix    = substr( $suffix, 0, $unique_id_index );
 810              return array(
 811                  'prefix'    => $prefix,
 812                  'suffix'    => empty( $suffix ) ? null : $suffix,
 813                  'unique_id' => empty( $unique_id ) ? null : $unique_id,
 814              );
 815          }
 816  
 817          return array(
 818              'prefix'    => $prefix,
 819              'suffix'    => empty( $suffix ) ? null : $suffix,
 820              'unique_id' => null,
 821          );
 822      }
 823  
 824      /**
 825       * Parses and extracts the namespace and reference path from the given
 826       * directive attribute value.
 827       *
 828       * If the value doesn't contain an explicit namespace, it returns the
 829       * default one. If the value contains a JSON object instead of a reference
 830       * path, the function tries to parse it and return the resulting array. If
 831       * the value contains strings that represent booleans ("true" and "false"),
 832       * numbers ("1" and "1.2") or "null", the function also transform them to
 833       * regular booleans, numbers and `null`.
 834       *
 835       * Example:
 836       *
 837       *     extract_directive_value( 'actions.foo', 'myPlugin' )                      => array( 'myPlugin', 'actions.foo' )
 838       *     extract_directive_value( 'otherPlugin::actions.foo', 'myPlugin' )         => array( 'otherPlugin', 'actions.foo' )
 839       *     extract_directive_value( '{ "isOpen": false }', 'myPlugin' )              => array( 'myPlugin', array( 'isOpen' => false ) )
 840       *     extract_directive_value( 'otherPlugin::{ "isOpen": false }', 'myPlugin' ) => array( 'otherPlugin', array( 'isOpen' => false ) )
 841       *
 842       * @since 6.5.0
 843       *
 844       * @param string|true $directive_value   The directive attribute value. It can be `true` when it's a boolean
 845       *                                       attribute.
 846       * @param string|null $default_namespace Optional. The default namespace if none is explicitly defined.
 847       * @return array An array containing the namespace in the first item and the JSON, the reference path, or null on the
 848       *               second item.
 849       */
 850  	private function extract_directive_value( $directive_value, $default_namespace = null ): array {
 851          if ( empty( $directive_value ) || is_bool( $directive_value ) ) {
 852              return array( $default_namespace, null );
 853          }
 854  
 855          // Replaces the value and namespace if there is a namespace in the value.
 856          if ( 1 === preg_match( '/^([\w\-_\/]+)::./', $directive_value ) ) {
 857              list($default_namespace, $directive_value) = explode( '::', $directive_value, 2 );
 858          }
 859  
 860          /*
 861           * Tries to decode the value as a JSON object. If it fails and the value
 862           * isn't `null`, it returns the value as it is. Otherwise, it returns the
 863           * decoded JSON or null for the string `null`.
 864           */
 865          $decoded_json = json_decode( $directive_value, true );
 866          if ( null !== $decoded_json || 'null' === $directive_value ) {
 867              $directive_value = $decoded_json;
 868          }
 869  
 870          return array( $default_namespace, $directive_value );
 871      }
 872  
 873      /**
 874       * Parse the HTML element and get all the valid directives with the given prefix.
 875       *
 876       * @since 6.9.0
 877       *
 878       * @param WP_Interactivity_API_Directives_Processor $p      The directives processor instance.
 879       * @param string                                    $prefix The directive prefix to filter by.
 880       * @return array An array of entries containing the directive namespace, value, suffix, and unique ID.
 881       */
 882  	private function get_directive_entries( WP_Interactivity_API_Directives_Processor $p, string $prefix ) {
 883          $directive_attributes = $p->get_attribute_names_with_prefix( 'data-wp-' . $prefix );
 884          $entries              = array();
 885          foreach ( $directive_attributes as $attribute_name ) {
 886              [ 'prefix' => $attr_prefix, 'suffix' => $suffix, 'unique_id' => $unique_id] = $this->parse_directive_name( $attribute_name );
 887              // Ensure it is the desired directive.
 888              if ( $prefix !== $attr_prefix ) {
 889                  continue;
 890              }
 891              list( $namespace, $value ) = $this->extract_directive_value( $p->get_attribute( $attribute_name ), end( $this->namespace_stack ) );
 892              $entries[]                 = array(
 893                  'namespace' => $namespace,
 894                  'value'     => $value,
 895                  'suffix'    => $suffix,
 896                  'unique_id' => $unique_id,
 897              );
 898          }
 899          // Sort directive entries to ensure stable ordering with the client.
 900          // Put nulls first, then sort by suffix and finally by uniqueIds.
 901          usort(
 902              $entries,
 903              function ( $a, $b ) {
 904                  $a_suffix = $a['suffix'] ?? '';
 905                  $b_suffix = $b['suffix'] ?? '';
 906                  if ( $a_suffix !== $b_suffix ) {
 907                      return $a_suffix <=> $b_suffix;
 908                  }
 909                  $a_id = $a['unique_id'] ?? '';
 910                  $b_id = $b['unique_id'] ?? '';
 911                  return $a_id <=> $b_id;
 912              }
 913          );
 914          return $entries;
 915      }
 916  
 917      /**
 918       * Transforms a kebab-case string to camelCase.
 919       *
 920       * @since 6.5.0
 921       *
 922       * @param string $str The kebab-case string to transform to camelCase.
 923       * @return string The transformed camelCase string.
 924       */
 925  	private function kebab_to_camel_case( string $str ): string {
 926          return lcfirst(
 927              preg_replace_callback(
 928                  '/(-)([a-z])/',
 929                  function ( $matches ) {
 930                      return strtoupper( $matches[2] );
 931                  },
 932                  strtolower( rtrim( $str, '-' ) )
 933              )
 934          );
 935      }
 936  
 937      /**
 938       * Processes the `data-wp-interactive` directive.
 939       *
 940       * It adds the default store namespace defined in the directive value to the
 941       * stack so that it's available for the nested interactivity elements.
 942       *
 943       * @since 6.5.0
 944       *
 945       * @param WP_Interactivity_API_Directives_Processor $p    The directives processor instance.
 946       * @param string                                    $mode Whether the processing is entering or exiting the tag.
 947       */
 948  	private function data_wp_interactive_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
 949          // When exiting tags, it removes the last namespace from the stack.
 950          if ( 'exit' === $mode ) {
 951              array_pop( $this->namespace_stack );
 952              return;
 953          }
 954  
 955          // Tries to decode the `data-wp-interactive` attribute value.
 956          $attribute_value = $p->get_attribute( 'data-wp-interactive' );
 957  
 958          /*
 959           * Pushes the newly defined namespace or the current one if the
 960           * `data-wp-interactive` definition was invalid or does not contain a
 961           * namespace. It does so because the function pops out the current namespace
 962           * from the stack whenever it finds a `data-wp-interactive`'s closing tag,
 963           * independently of whether the previous `data-wp-interactive` definition
 964           * contained a valid namespace.
 965           */
 966          $new_namespace = null;
 967          if ( is_string( $attribute_value ) && ! empty( $attribute_value ) ) {
 968              $decoded_json = json_decode( $attribute_value, true );
 969              if ( is_array( $decoded_json ) ) {
 970                  $new_namespace = $decoded_json['namespace'] ?? null;
 971              } else {
 972                  $new_namespace = $attribute_value;
 973              }
 974          }
 975          $this->namespace_stack[] = ( $new_namespace && 1 === preg_match( '/^([\w\-_\/]+)/', $new_namespace ) )
 976              ? $new_namespace
 977              : end( $this->namespace_stack );
 978      }
 979  
 980      /**
 981       * Processes the `data-wp-context` directive.
 982       *
 983       * It adds the context defined in the directive value to the stack so that
 984       * it's available for the nested interactivity elements.
 985       *
 986       * @since 6.5.0
 987       *
 988       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
 989       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
 990       */
 991  	private function data_wp_context_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
 992          // When exiting tags, it removes the last context from the stack.
 993          if ( 'exit' === $mode ) {
 994              array_pop( $this->context_stack );
 995              return;
 996          }
 997  
 998          $entries = $this->get_directive_entries( $p, 'context' );
 999          $context = end( $this->context_stack ) !== false ? end( $this->context_stack ) : array();
1000          foreach ( $entries as $entry ) {
1001              if ( null !== $entry['suffix'] ) {
1002                  continue;
1003              }
1004  
1005              $context = array_replace_recursive(
1006                  $context,
1007                  array( $entry['namespace'] => is_array( $entry['value'] ) ? $entry['value'] : array() )
1008              );
1009          }
1010          $this->context_stack[] = $context;
1011      }
1012  
1013      /**
1014       * Processes the `data-wp-bind` directive.
1015       *
1016       * It updates or removes the bound attributes based on the evaluation of its
1017       * associated reference.
1018       *
1019       * @since 6.5.0
1020       *
1021       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
1022       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
1023       */
1024  	private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
1025          if ( 'enter' === $mode ) {
1026              $entries = $this->get_directive_entries( $p, 'bind' );
1027              foreach ( $entries as $entry ) {
1028                  if ( empty( $entry['suffix'] ) || null !== $entry['unique_id'] ) {
1029                          return;
1030                  }
1031  
1032                  $result = $this->evaluate( $entry );
1033  
1034                  if (
1035                      null !== $result &&
1036                      (
1037                          false !== $result ||
1038                          ( strlen( $entry['suffix'] ) > 5 && '-' === $entry['suffix'][4] )
1039                      )
1040                  ) {
1041                      /*
1042                       * If the result of the evaluation is a boolean and the attribute is
1043                       * `aria-` or `data-, convert it to a string "true" or "false". It
1044                       * follows the exact same logic as Preact because it needs to
1045                       * replicate what Preact will later do in the client:
1046                       * https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136
1047                       */
1048                      if (
1049                          is_bool( $result ) &&
1050                          ( strlen( $entry['suffix'] ) > 5 && '-' === $entry['suffix'][4] )
1051                      ) {
1052                          $result = $result ? 'true' : 'false';
1053                      }
1054                      $p->set_attribute( $entry['suffix'], $result );
1055                  } else {
1056                      $p->remove_attribute( $entry['suffix'] );
1057                  }
1058              }
1059          }
1060      }
1061  
1062      /**
1063       * Processes the `data-wp-class` directive.
1064       *
1065       * It adds or removes CSS classes in the current HTML element based on the
1066       * evaluation of its associated references.
1067       *
1068       * @since 6.5.0
1069       *
1070       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
1071       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
1072       */
1073  	private function data_wp_class_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
1074          if ( 'enter' === $mode ) {
1075              $all_class_directives = $p->get_attribute_names_with_prefix( 'data-wp-class--' );
1076              $entries              = $this->get_directive_entries( $p, 'class' );
1077              foreach ( $entries as $entry ) {
1078                  if ( empty( $entry['suffix'] ) ) {
1079                      continue;
1080                  }
1081                  $class_name = isset( $entry['unique_id'] ) && $entry['unique_id']
1082                      ? "{$entry['suffix']}---{$entry['unique_id']}"
1083                      : $entry['suffix'];
1084  
1085                  if ( empty( $class_name ) ) {
1086                      return;
1087                  }
1088  
1089                  $result = $this->evaluate( $entry );
1090  
1091                  if ( $result ) {
1092                      $p->add_class( $class_name );
1093                  } else {
1094                      $p->remove_class( $class_name );
1095                  }
1096              }
1097          }
1098      }
1099  
1100      /**
1101       * Processes the `data-wp-style` directive.
1102       *
1103       * It updates the style attribute value of the current HTML element based on
1104       * the evaluation of its associated references.
1105       *
1106       * @since 6.5.0
1107       *
1108       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
1109       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
1110       */
1111  	private function data_wp_style_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
1112          if ( 'enter' === $mode ) {
1113              $entries = $this->get_directive_entries( $p, 'style' );
1114              foreach ( $entries as $entry ) {
1115                  $style_property = $entry['suffix'];
1116                  if ( empty( $style_property ) || null !== $entry['unique_id'] ) {
1117                      continue;
1118                  }
1119  
1120                  $style_property_value  = $this->evaluate( $entry );
1121                  $style_attribute_value = $p->get_attribute( 'style' );
1122                  $style_attribute_value = ( $style_attribute_value && ! is_bool( $style_attribute_value ) ) ? $style_attribute_value : '';
1123  
1124                  /*
1125                   * Checks first if the style property is not falsy and the style
1126                   * attribute value is not empty because if it is, it doesn't need to
1127                   * update the attribute value.
1128                   */
1129                  if ( $style_property_value || $style_attribute_value ) {
1130                      $style_attribute_value = $this->merge_style_property( $style_attribute_value, $style_property, $style_property_value );
1131                      /*
1132                       * If the style attribute value is not empty, it sets it. Otherwise,
1133                       * it removes it.
1134                       */
1135                      if ( ! empty( $style_attribute_value ) ) {
1136                          $p->set_attribute( 'style', $style_attribute_value );
1137                      } else {
1138                          $p->remove_attribute( 'style' );
1139                      }
1140                  }
1141              }
1142          }
1143      }
1144  
1145      /**
1146       * Merges an individual style property in the `style` attribute of an HTML
1147       * element, updating or removing the property when necessary.
1148       *
1149       * If a property is modified, the old one is removed and the new one is added
1150       * at the end of the list.
1151       *
1152       * @since 6.5.0
1153       *
1154       * Example:
1155       *
1156       *     merge_style_property( 'color:green;', 'color', 'red' )      => 'color:red;'
1157       *     merge_style_property( 'background:green;', 'color', 'red' ) => 'background:green;color:red;'
1158       *     merge_style_property( 'color:green;', 'color', null )       => ''
1159       *
1160       * @param string            $style_attribute_value The current style attribute value.
1161       * @param string            $style_property_name   The style property name to set.
1162       * @param string|false|null $style_property_value  The value to set for the style property. With false, null or an
1163       *                                                 empty string, it removes the style property.
1164       * @return string The new style attribute value after the specified property has been added, updated or removed.
1165       */
1166  	private function merge_style_property( string $style_attribute_value, string $style_property_name, $style_property_value ): string {
1167          $style_assignments    = explode( ';', $style_attribute_value );
1168          $result               = array();
1169          $style_property_value = ! empty( $style_property_value ) ? rtrim( trim( $style_property_value ), ';' ) : null;
1170          $new_style_property   = $style_property_value ? $style_property_name . ':' . $style_property_value . ';' : '';
1171  
1172          // Generates an array with all the properties but the modified one.
1173          foreach ( $style_assignments as $style_assignment ) {
1174              if ( empty( trim( $style_assignment ) ) ) {
1175                  continue;
1176              }
1177              list( $name, $value ) = explode( ':', $style_assignment );
1178              if ( trim( $name ) !== $style_property_name ) {
1179                  $result[] = trim( $name ) . ':' . trim( $value ) . ';';
1180              }
1181          }
1182  
1183          // Adds the new/modified property at the end of the list.
1184          $result[] = $new_style_property;
1185  
1186          return implode( '', $result );
1187      }
1188  
1189      /**
1190       * Processes the `data-wp-text` directive.
1191       *
1192       * It updates the inner content of the current HTML element based on the
1193       * evaluation of its associated reference.
1194       *
1195       * @since 6.5.0
1196       *
1197       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
1198       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
1199       */
1200  	private function data_wp_text_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
1201          if ( 'enter' === $mode ) {
1202              $entries     = $this->get_directive_entries( $p, 'text' );
1203              $valid_entry = null;
1204              // Get the first valid `data-wp-text` entry without suffix or unique ID.
1205              foreach ( $entries as $entry ) {
1206                  if ( null === $entry['suffix'] && null === $entry['unique_id'] && ! empty( $entry['value'] ) ) {
1207                      $valid_entry = $entry;
1208                      break;
1209                  }
1210              }
1211              if ( null === $valid_entry ) {
1212                  return;
1213              }
1214              $result = $this->evaluate( $valid_entry );
1215  
1216              /*
1217               * Follows the same logic as Preact in the client and only changes the
1218               * content if the value is a string or a number. Otherwise, it removes the
1219               * content.
1220               */
1221              if ( is_string( $result ) || is_numeric( $result ) ) {
1222                  $p->set_content_between_balanced_tags( esc_html( $result ) );
1223              } else {
1224                  $p->set_content_between_balanced_tags( '' );
1225              }
1226          }
1227      }
1228  
1229      /**
1230       * Returns the CSS styles for animating the top loading bar in the router.
1231       *
1232       * @since 6.5.0
1233       *
1234       * @return string The CSS styles for the router's top loading bar animation.
1235       */
1236  	private function get_router_animation_styles(): string {
1237          return <<<CSS
1238              .wp-interactivity-router-loading-bar {
1239                  position: fixed;
1240                  top: 0;
1241                  left: 0;
1242                  margin: 0;
1243                  padding: 0;
1244                  width: 100vw;
1245                  max-width: 100vw !important;
1246                  height: 4px;
1247                  background-color: #000;
1248                  opacity: 0
1249              }
1250              .wp-interactivity-router-loading-bar.start-animation {
1251                  animation: wp-interactivity-router-loading-bar-start-animation 30s cubic-bezier(0.03, 0.5, 0, 1) forwards
1252              }
1253              .wp-interactivity-router-loading-bar.finish-animation {
1254                  animation: wp-interactivity-router-loading-bar-finish-animation 300ms ease-in
1255              }
1256              @keyframes wp-interactivity-router-loading-bar-start-animation {
1257                  0% { transform: scaleX(0); transform-origin: 0 0; opacity: 1 }
1258                  100% { transform: scaleX(1); transform-origin: 0 0; opacity: 1 }
1259              }
1260              @keyframes wp-interactivity-router-loading-bar-finish-animation {
1261                  0% { opacity: 1 }
1262                  50% { opacity: 1 }
1263                  100% { opacity: 0 }
1264              }
1265  CSS;
1266      }
1267  
1268      /**
1269       * Deprecated.
1270       *
1271       * @since 6.5.0
1272       * @deprecated 6.7.0 Use {@see WP_Interactivity_API::print_router_markup} instead.
1273       */
1274  	public function print_router_loading_and_screen_reader_markup() {
1275          _deprecated_function( __METHOD__, '6.7.0', 'WP_Interactivity_API::print_router_markup' );
1276  
1277          // Call the new method.
1278          $this->print_router_markup();
1279      }
1280  
1281      /**
1282       * Outputs markup for the @wordpress/interactivity-router script module.
1283       *
1284       * This method prints a div element representing a loading bar visible during
1285       * navigation.
1286       *
1287       * @since 6.7.0
1288       */
1289  	public function print_router_markup() {
1290          echo <<<HTML
1291              <div
1292                  class="wp-interactivity-router-loading-bar"
1293                  data-wp-interactive="core/router"
1294                  data-wp-class--start-animation="state.navigation.hasStarted"
1295                  data-wp-class--finish-animation="state.navigation.hasFinished"
1296              ></div>
1297  HTML;
1298      }
1299  
1300      /**
1301       * Processes the `data-wp-router-region` directive.
1302       *
1303       * It renders in the footer a set of HTML elements to notify users about
1304       * client-side navigations. More concretely, the elements added are 1) a
1305       * top loading bar to visually inform that a navigation is in progress
1306       * and 2) an `aria-live` region for accessible navigation announcements.
1307       *
1308       * @since 6.5.0
1309       *
1310       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
1311       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
1312       */
1313  	private function data_wp_router_region_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
1314          if ( 'enter' === $mode && ! $this->has_processed_router_region ) {
1315              $this->has_processed_router_region = true;
1316  
1317              // Enqueues as an inline style.
1318              wp_register_style( 'wp-interactivity-router-animations', false );
1319              wp_add_inline_style( 'wp-interactivity-router-animations', $this->get_router_animation_styles() );
1320              wp_enqueue_style( 'wp-interactivity-router-animations' );
1321  
1322              // Adds the necessary markup to the footer.
1323              add_action( 'wp_footer', array( $this, 'print_router_markup' ) );
1324          }
1325      }
1326  
1327      /**
1328       * Processes the `data-wp-each` directive.
1329       *
1330       * This directive gets an array passed as reference and iterates over it
1331       * generating new content for each item based on the inner markup of the
1332       * `template` tag.
1333       *
1334       * @since 6.5.0
1335       * @since 6.9.0 Include the list path in the rendered `data-wp-each-child` directives.
1336       *
1337       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
1338       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
1339       * @param array                                     $tag_stack       The reference to the tag stack.
1340       */
1341  	private function data_wp_each_processor( WP_Interactivity_API_Directives_Processor $p, string $mode, array &$tag_stack ) {
1342          if ( 'enter' === $mode && 'TEMPLATE' === $p->get_tag() ) {
1343              $entries = $this->get_directive_entries( $p, 'each' );
1344              if ( count( $entries ) > 1 || empty( $entries ) ) {
1345                  // There should be only one `data-wp-each` directive per template tag.
1346                  return;
1347              }
1348              $entry = $entries[0];
1349              if ( null !== $entry['unique_id'] ) {
1350                  return;
1351              }
1352              $item_name = isset( $entry['suffix'] ) ? $this->kebab_to_camel_case( $entry['suffix'] ) : 'item';
1353              $result    = $this->evaluate( $entry );
1354  
1355              // Gets the content between the template tags and leaves the cursor in the closer tag.
1356              $inner_content = $p->get_content_between_balanced_template_tags();
1357  
1358              // Checks if there is a manual server-side directive processing.
1359              $template_end = 'data-wp-each: template end';
1360              $p->set_bookmark( $template_end );
1361              $p->next_tag();
1362              $manual_sdp = $p->get_attribute( 'data-wp-each-child' );
1363              $p->seek( $template_end ); // Rewinds to the template closer tag.
1364              $p->release_bookmark( $template_end );
1365  
1366              /*
1367               * It doesn't process in these situations:
1368               * - Manual server-side directive processing.
1369               * - Empty or non-array values.
1370               * - Associative arrays because those are deserialized as objects in JS.
1371               * - Templates that contain top-level texts because those texts can't be
1372               *   identified and removed in the client.
1373               */
1374              if (
1375                  $manual_sdp ||
1376                  empty( $result ) ||
1377                  ! is_array( $result ) ||
1378                  ! array_is_list( $result ) ||
1379                  ! str_starts_with( trim( $inner_content ), '<' ) ||
1380                  ! str_ends_with( trim( $inner_content ), '>' )
1381              ) {
1382                  array_pop( $tag_stack );
1383                  return;
1384              }
1385  
1386              // Processes the inner content for each item of the array.
1387              $processed_content = '';
1388              foreach ( $result as $item ) {
1389                  // Creates a new context that includes the current item of the array.
1390                  $this->context_stack[] = array_replace_recursive(
1391                      end( $this->context_stack ) !== false ? end( $this->context_stack ) : array(),
1392                      array( $entry['namespace'] => array( $item_name => $item ) )
1393                  );
1394  
1395                  // Processes the inner content with the new context.
1396                  $processed_item = $this->_process_directives( $inner_content );
1397  
1398                  if ( null === $processed_item ) {
1399                      // If the HTML is unbalanced, stop processing it.
1400                      array_pop( $this->context_stack );
1401                      return;
1402                  }
1403  
1404                  /*
1405                   * Adds the `data-wp-each-child` directive to each top-level tag
1406                   * rendered by this `data-wp-each` directive. The value is the
1407                   * `data-wp-each` directive's namespace and path.
1408                   *
1409                   * Nested `data-wp-each` directives could render
1410                   * `data-wp-each-child` elements at the top level as well, and
1411                   * they should be overwritten.
1412                   *
1413                   * @since 6.9.0
1414                   */
1415                  $i = new WP_Interactivity_API_Directives_Processor( $processed_item );
1416                  while ( $i->next_tag() ) {
1417                      $i->set_attribute( 'data-wp-each-child', $entry['namespace'] . '::' . $entry['value'] );
1418                      $i->next_balanced_tag_closer_tag();
1419                  }
1420                  $processed_content .= $i->get_updated_html();
1421  
1422                  // Removes the current context from the stack.
1423                  array_pop( $this->context_stack );
1424              }
1425  
1426              // Appends the processed content after the tag closer of the template.
1427              $p->append_content_after_template_tag_closer( $processed_content );
1428  
1429              // Pops the last tag because it skipped the closing tag of the template tag.
1430              array_pop( $tag_stack );
1431          }
1432      }
1433  }


Generated : Wed Apr 15 08:20:10 2026 Cross-referenced by PHPXref