[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

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

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


Generated : Thu Nov 21 08:20:01 2024 Cross-referenced by PHPXref