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


Generated : Mon Nov 9 08:20:01 2026 Cross-referenced by PHPXref