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


Generated : Fri Jul 31 08:20:18 2026 Cross-referenced by PHPXref