[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

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

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


Generated : Tue Oct 20 08:20:34 2026 Cross-referenced by PHPXref