[ 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              /*
 583               * Special case for numeric arrays and strings. Add length
 584               * property mimicking JavaScript behavior.
 585               *
 586               * @since 6.8.0
 587               */
 588              if ( 'length' === $path_segment ) {
 589                  if ( is_array( $current ) && array_is_list( $current ) ) {
 590                      $current = count( $current );
 591                      break;
 592                  }
 593  
 594                  if ( is_string( $current ) ) {
 595                      /*
 596                       * Differences in encoding between PHP strings and
 597                       * JavaScript mean that it's complicated to calculate
 598                       * the string length JavaScript would see from PHP.
 599                       * `strlen` is a reasonable approximation.
 600                       *
 601                       * Users that desire a more precise length likely have
 602                       * more precise needs than "bytelength" and should
 603                       * implement their own length calculation in derived
 604                       * state taking into account encoding and their desired
 605                       * output (codepoints, graphemes, bytes, etc.).
 606                       */
 607                      $current = strlen( $current );
 608                      break;
 609                  }
 610              }
 611  
 612              if ( ( is_array( $current ) || $current instanceof ArrayAccess ) && isset( $current[ $path_segment ] ) ) {
 613                  $current = $current[ $path_segment ];
 614              } elseif ( is_object( $current ) && isset( $current->$path_segment ) ) {
 615                  $current = $current->$path_segment;
 616              } else {
 617                  $current = null;
 618                  break;
 619              }
 620  
 621              if ( $current instanceof Closure ) {
 622                  /*
 623                   * This state getter's namespace is added to the stack so that
 624                   * `state()` or `get_config()` read that namespace when called
 625                   * without specifying one.
 626                   */
 627                  array_push( $this->namespace_stack, $ns );
 628                  try {
 629                      $current = $current();
 630                  } catch ( Throwable $e ) {
 631                      _doing_it_wrong(
 632                          __METHOD__,
 633                          sprintf(
 634                              /* translators: 1: Path pointing to an Interactivity API state property, 2: Namespace for an Interactivity API store. */
 635                              __( 'Uncaught error executing a derived state callback with path "%1$s" and namespace "%2$s".' ),
 636                              $path,
 637                              $ns
 638                          ),
 639                          '6.6.0'
 640                      );
 641                      return null;
 642                  } finally {
 643                      // Remove the property's namespace from the stack.
 644                      array_pop( $this->namespace_stack );
 645                  }
 646              }
 647          }
 648  
 649          // Returns the opposite if it contains a negation operator (!).
 650          return $should_negate_value ? ! $current : $current;
 651      }
 652  
 653      /**
 654       * Extracts the directive attribute name to separate and return the directive
 655       * prefix and an optional suffix.
 656       *
 657       * The suffix is the string after the first double hyphen and the prefix is
 658       * everything that comes before the suffix.
 659       *
 660       * Example:
 661       *
 662       *     extract_prefix_and_suffix( 'data-wp-interactive' )   => array( 'data-wp-interactive', null )
 663       *     extract_prefix_and_suffix( 'data-wp-bind--src' )     => array( 'data-wp-bind', 'src' )
 664       *     extract_prefix_and_suffix( 'data-wp-foo--and--bar' ) => array( 'data-wp-foo', 'and--bar' )
 665       *
 666       * @since 6.5.0
 667       *
 668       * @param string $directive_name The directive attribute name.
 669       * @return array An array containing the directive prefix and optional suffix.
 670       */
 671  	private function extract_prefix_and_suffix( string $directive_name ): array {
 672          return explode( '--', $directive_name, 2 );
 673      }
 674  
 675      /**
 676       * Parses and extracts the namespace and reference path from the given
 677       * directive attribute value.
 678       *
 679       * If the value doesn't contain an explicit namespace, it returns the
 680       * default one. If the value contains a JSON object instead of a reference
 681       * path, the function tries to parse it and return the resulting array. If
 682       * the value contains strings that represent booleans ("true" and "false"),
 683       * numbers ("1" and "1.2") or "null", the function also transform them to
 684       * regular booleans, numbers and `null`.
 685       *
 686       * Example:
 687       *
 688       *     extract_directive_value( 'actions.foo', 'myPlugin' )                      => array( 'myPlugin', 'actions.foo' )
 689       *     extract_directive_value( 'otherPlugin::actions.foo', 'myPlugin' )         => array( 'otherPlugin', 'actions.foo' )
 690       *     extract_directive_value( '{ "isOpen": false }', 'myPlugin' )              => array( 'myPlugin', array( 'isOpen' => false ) )
 691       *     extract_directive_value( 'otherPlugin::{ "isOpen": false }', 'myPlugin' ) => array( 'otherPlugin', array( 'isOpen' => false ) )
 692       *
 693       * @since 6.5.0
 694       *
 695       * @param string|true $directive_value   The directive attribute value. It can be `true` when it's a boolean
 696       *                                       attribute.
 697       * @param string|null $default_namespace Optional. The default namespace if none is explicitly defined.
 698       * @return array An array containing the namespace in the first item and the JSON, the reference path, or null on the
 699       *               second item.
 700       */
 701  	private function extract_directive_value( $directive_value, $default_namespace = null ): array {
 702          if ( empty( $directive_value ) || is_bool( $directive_value ) ) {
 703              return array( $default_namespace, null );
 704          }
 705  
 706          // Replaces the value and namespace if there is a namespace in the value.
 707          if ( 1 === preg_match( '/^([\w\-_\/]+)::./', $directive_value ) ) {
 708              list($default_namespace, $directive_value) = explode( '::', $directive_value, 2 );
 709          }
 710  
 711          /*
 712           * Tries to decode the value as a JSON object. If it fails and the value
 713           * isn't `null`, it returns the value as it is. Otherwise, it returns the
 714           * decoded JSON or null for the string `null`.
 715           */
 716          $decoded_json = json_decode( $directive_value, true );
 717          if ( null !== $decoded_json || 'null' === $directive_value ) {
 718              $directive_value = $decoded_json;
 719          }
 720  
 721          return array( $default_namespace, $directive_value );
 722      }
 723  
 724      /**
 725       * Transforms a kebab-case string to camelCase.
 726       *
 727       * @param string $str The kebab-case string to transform to camelCase.
 728       * @return string The transformed camelCase string.
 729       */
 730  	private function kebab_to_camel_case( string $str ): string {
 731          return lcfirst(
 732              preg_replace_callback(
 733                  '/(-)([a-z])/',
 734                  function ( $matches ) {
 735                      return strtoupper( $matches[2] );
 736                  },
 737                  strtolower( rtrim( $str, '-' ) )
 738              )
 739          );
 740      }
 741  
 742      /**
 743       * Processes the `data-wp-interactive` directive.
 744       *
 745       * It adds the default store namespace defined in the directive value to the
 746       * stack so that it's available for the nested interactivity elements.
 747       *
 748       * @since 6.5.0
 749       *
 750       * @param WP_Interactivity_API_Directives_Processor $p    The directives processor instance.
 751       * @param string                                    $mode Whether the processing is entering or exiting the tag.
 752       */
 753  	private function data_wp_interactive_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
 754          // When exiting tags, it removes the last namespace from the stack.
 755          if ( 'exit' === $mode ) {
 756              array_pop( $this->namespace_stack );
 757              return;
 758          }
 759  
 760          // Tries to decode the `data-wp-interactive` attribute value.
 761          $attribute_value = $p->get_attribute( 'data-wp-interactive' );
 762  
 763          /*
 764           * Pushes the newly defined namespace or the current one if the
 765           * `data-wp-interactive` definition was invalid or does not contain a
 766           * namespace. It does so because the function pops out the current namespace
 767           * from the stack whenever it finds a `data-wp-interactive`'s closing tag,
 768           * independently of whether the previous `data-wp-interactive` definition
 769           * contained a valid namespace.
 770           */
 771          $new_namespace = null;
 772          if ( is_string( $attribute_value ) && ! empty( $attribute_value ) ) {
 773              $decoded_json = json_decode( $attribute_value, true );
 774              if ( is_array( $decoded_json ) ) {
 775                  $new_namespace = $decoded_json['namespace'] ?? null;
 776              } else {
 777                  $new_namespace = $attribute_value;
 778              }
 779          }
 780          $this->namespace_stack[] = ( $new_namespace && 1 === preg_match( '/^([\w\-_\/]+)/', $new_namespace ) )
 781              ? $new_namespace
 782              : end( $this->namespace_stack );
 783      }
 784  
 785      /**
 786       * Processes the `data-wp-context` directive.
 787       *
 788       * It adds the context defined in the directive value to the stack so that
 789       * it's available for the nested interactivity elements.
 790       *
 791       * @since 6.5.0
 792       *
 793       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
 794       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
 795       */
 796  	private function data_wp_context_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
 797          // When exiting tags, it removes the last context from the stack.
 798          if ( 'exit' === $mode ) {
 799              array_pop( $this->context_stack );
 800              return;
 801          }
 802  
 803          $attribute_value = $p->get_attribute( 'data-wp-context' );
 804          $namespace_value = end( $this->namespace_stack );
 805  
 806          // Separates the namespace from the context JSON object.
 807          list( $namespace_value, $decoded_json ) = is_string( $attribute_value ) && ! empty( $attribute_value )
 808              ? $this->extract_directive_value( $attribute_value, $namespace_value )
 809              : array( $namespace_value, null );
 810  
 811          /*
 812           * If there is a namespace, it adds a new context to the stack merging the
 813           * previous context with the new one.
 814           */
 815          if ( is_string( $namespace_value ) ) {
 816              $this->context_stack[] = array_replace_recursive(
 817                  end( $this->context_stack ) !== false ? end( $this->context_stack ) : array(),
 818                  array( $namespace_value => is_array( $decoded_json ) ? $decoded_json : array() )
 819              );
 820          } else {
 821              /*
 822               * If there is no namespace, it pushes the current context to the stack.
 823               * It needs to do so because the function pops out the current context
 824               * from the stack whenever it finds a `data-wp-context`'s closing tag.
 825               */
 826              $this->context_stack[] = end( $this->context_stack );
 827          }
 828      }
 829  
 830      /**
 831       * Processes the `data-wp-bind` directive.
 832       *
 833       * It updates or removes the bound attributes based on the evaluation of its
 834       * associated reference.
 835       *
 836       * @since 6.5.0
 837       *
 838       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
 839       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
 840       */
 841  	private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
 842          if ( 'enter' === $mode ) {
 843              $all_bind_directives = $p->get_attribute_names_with_prefix( 'data-wp-bind--' );
 844  
 845              foreach ( $all_bind_directives as $attribute_name ) {
 846                  list( , $bound_attribute ) = $this->extract_prefix_and_suffix( $attribute_name );
 847                  if ( empty( $bound_attribute ) ) {
 848                      return;
 849                  }
 850  
 851                  $attribute_value = $p->get_attribute( $attribute_name );
 852                  $result          = $this->evaluate( $attribute_value );
 853  
 854                  if (
 855                      null !== $result &&
 856                      (
 857                          false !== $result ||
 858                          ( strlen( $bound_attribute ) > 5 && '-' === $bound_attribute[4] )
 859                      )
 860                  ) {
 861                      /*
 862                       * If the result of the evaluation is a boolean and the attribute is
 863                       * `aria-` or `data-, convert it to a string "true" or "false". It
 864                       * follows the exact same logic as Preact because it needs to
 865                       * replicate what Preact will later do in the client:
 866                       * https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136
 867                       */
 868                      if (
 869                          is_bool( $result ) &&
 870                          ( strlen( $bound_attribute ) > 5 && '-' === $bound_attribute[4] )
 871                      ) {
 872                          $result = $result ? 'true' : 'false';
 873                      }
 874                      $p->set_attribute( $bound_attribute, $result );
 875                  } else {
 876                      $p->remove_attribute( $bound_attribute );
 877                  }
 878              }
 879          }
 880      }
 881  
 882      /**
 883       * Processes the `data-wp-class` directive.
 884       *
 885       * It adds or removes CSS classes in the current HTML element based on the
 886       * evaluation of its associated references.
 887       *
 888       * @since 6.5.0
 889       *
 890       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
 891       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
 892       */
 893  	private function data_wp_class_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
 894          if ( 'enter' === $mode ) {
 895              $all_class_directives = $p->get_attribute_names_with_prefix( 'data-wp-class--' );
 896  
 897              foreach ( $all_class_directives as $attribute_name ) {
 898                  list( , $class_name ) = $this->extract_prefix_and_suffix( $attribute_name );
 899                  if ( empty( $class_name ) ) {
 900                      return;
 901                  }
 902  
 903                  $attribute_value = $p->get_attribute( $attribute_name );
 904                  $result          = $this->evaluate( $attribute_value );
 905  
 906                  if ( $result ) {
 907                      $p->add_class( $class_name );
 908                  } else {
 909                      $p->remove_class( $class_name );
 910                  }
 911              }
 912          }
 913      }
 914  
 915      /**
 916       * Processes the `data-wp-style` directive.
 917       *
 918       * It updates the style attribute value of the current HTML element based on
 919       * the evaluation of its associated references.
 920       *
 921       * @since 6.5.0
 922       *
 923       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
 924       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
 925       */
 926  	private function data_wp_style_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
 927          if ( 'enter' === $mode ) {
 928              $all_style_attributes = $p->get_attribute_names_with_prefix( 'data-wp-style--' );
 929  
 930              foreach ( $all_style_attributes as $attribute_name ) {
 931                  list( , $style_property ) = $this->extract_prefix_and_suffix( $attribute_name );
 932                  if ( empty( $style_property ) ) {
 933                      continue;
 934                  }
 935  
 936                  $directive_attribute_value = $p->get_attribute( $attribute_name );
 937                  $style_property_value      = $this->evaluate( $directive_attribute_value );
 938                  $style_attribute_value     = $p->get_attribute( 'style' );
 939                  $style_attribute_value     = ( $style_attribute_value && ! is_bool( $style_attribute_value ) ) ? $style_attribute_value : '';
 940  
 941                  /*
 942                   * Checks first if the style property is not falsy and the style
 943                   * attribute value is not empty because if it is, it doesn't need to
 944                   * update the attribute value.
 945                   */
 946                  if ( $style_property_value || $style_attribute_value ) {
 947                      $style_attribute_value = $this->merge_style_property( $style_attribute_value, $style_property, $style_property_value );
 948                      /*
 949                       * If the style attribute value is not empty, it sets it. Otherwise,
 950                       * it removes it.
 951                       */
 952                      if ( ! empty( $style_attribute_value ) ) {
 953                          $p->set_attribute( 'style', $style_attribute_value );
 954                      } else {
 955                          $p->remove_attribute( 'style' );
 956                      }
 957                  }
 958              }
 959          }
 960      }
 961  
 962      /**
 963       * Merges an individual style property in the `style` attribute of an HTML
 964       * element, updating or removing the property when necessary.
 965       *
 966       * If a property is modified, the old one is removed and the new one is added
 967       * at the end of the list.
 968       *
 969       * @since 6.5.0
 970       *
 971       * Example:
 972       *
 973       *     merge_style_property( 'color:green;', 'color', 'red' )      => 'color:red;'
 974       *     merge_style_property( 'background:green;', 'color', 'red' ) => 'background:green;color:red;'
 975       *     merge_style_property( 'color:green;', 'color', null )       => ''
 976       *
 977       * @param string            $style_attribute_value The current style attribute value.
 978       * @param string            $style_property_name   The style property name to set.
 979       * @param string|false|null $style_property_value  The value to set for the style property. With false, null or an
 980       *                                                 empty string, it removes the style property.
 981       * @return string The new style attribute value after the specified property has been added, updated or removed.
 982       */
 983  	private function merge_style_property( string $style_attribute_value, string $style_property_name, $style_property_value ): string {
 984          $style_assignments    = explode( ';', $style_attribute_value );
 985          $result               = array();
 986          $style_property_value = ! empty( $style_property_value ) ? rtrim( trim( $style_property_value ), ';' ) : null;
 987          $new_style_property   = $style_property_value ? $style_property_name . ':' . $style_property_value . ';' : '';
 988  
 989          // Generates an array with all the properties but the modified one.
 990          foreach ( $style_assignments as $style_assignment ) {
 991              if ( empty( trim( $style_assignment ) ) ) {
 992                  continue;
 993              }
 994              list( $name, $value ) = explode( ':', $style_assignment );
 995              if ( trim( $name ) !== $style_property_name ) {
 996                  $result[] = trim( $name ) . ':' . trim( $value ) . ';';
 997              }
 998          }
 999  
1000          // Adds the new/modified property at the end of the list.
1001          $result[] = $new_style_property;
1002  
1003          return implode( '', $result );
1004      }
1005  
1006      /**
1007       * Processes the `data-wp-text` directive.
1008       *
1009       * It updates the inner content of the current HTML element based on the
1010       * evaluation of its associated reference.
1011       *
1012       * @since 6.5.0
1013       *
1014       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
1015       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
1016       */
1017  	private function data_wp_text_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
1018          if ( 'enter' === $mode ) {
1019              $attribute_value = $p->get_attribute( 'data-wp-text' );
1020              $result          = $this->evaluate( $attribute_value );
1021  
1022              /*
1023               * Follows the same logic as Preact in the client and only changes the
1024               * content if the value is a string or a number. Otherwise, it removes the
1025               * content.
1026               */
1027              if ( is_string( $result ) || is_numeric( $result ) ) {
1028                  $p->set_content_between_balanced_tags( esc_html( $result ) );
1029              } else {
1030                  $p->set_content_between_balanced_tags( '' );
1031              }
1032          }
1033      }
1034  
1035      /**
1036       * Returns the CSS styles for animating the top loading bar in the router.
1037       *
1038       * @since 6.5.0
1039       *
1040       * @return string The CSS styles for the router's top loading bar animation.
1041       */
1042  	private function get_router_animation_styles(): string {
1043          return <<<CSS
1044              .wp-interactivity-router-loading-bar {
1045                  position: fixed;
1046                  top: 0;
1047                  left: 0;
1048                  margin: 0;
1049                  padding: 0;
1050                  width: 100vw;
1051                  max-width: 100vw !important;
1052                  height: 4px;
1053                  background-color: #000;
1054                  opacity: 0
1055              }
1056              .wp-interactivity-router-loading-bar.start-animation {
1057                  animation: wp-interactivity-router-loading-bar-start-animation 30s cubic-bezier(0.03, 0.5, 0, 1) forwards
1058              }
1059              .wp-interactivity-router-loading-bar.finish-animation {
1060                  animation: wp-interactivity-router-loading-bar-finish-animation 300ms ease-in
1061              }
1062              @keyframes wp-interactivity-router-loading-bar-start-animation {
1063                  0% { transform: scaleX(0); transform-origin: 0 0; opacity: 1 }
1064                  100% { transform: scaleX(1); transform-origin: 0 0; opacity: 1 }
1065              }
1066              @keyframes wp-interactivity-router-loading-bar-finish-animation {
1067                  0% { opacity: 1 }
1068                  50% { opacity: 1 }
1069                  100% { opacity: 0 }
1070              }
1071  CSS;
1072      }
1073  
1074      /**
1075       * Deprecated.
1076       *
1077       * @since 6.5.0
1078       * @deprecated 6.7.0 Use {@see WP_Interactivity_API::print_router_markup} instead.
1079       */
1080  	public function print_router_loading_and_screen_reader_markup() {
1081          _deprecated_function( __METHOD__, '6.7.0', 'WP_Interactivity_API::print_router_markup' );
1082  
1083          // Call the new method.
1084          $this->print_router_markup();
1085      }
1086  
1087      /**
1088       * Outputs markup for the @wordpress/interactivity-router script module.
1089       *
1090       * This method prints a div element representing a loading bar visible during
1091       * navigation.
1092       *
1093       * @since 6.7.0
1094       */
1095  	public function print_router_markup() {
1096          echo <<<HTML
1097              <div
1098                  class="wp-interactivity-router-loading-bar"
1099                  data-wp-interactive="core/router"
1100                  data-wp-class--start-animation="state.navigation.hasStarted"
1101                  data-wp-class--finish-animation="state.navigation.hasFinished"
1102              ></div>
1103  HTML;
1104      }
1105  
1106      /**
1107       * Processes the `data-wp-router-region` directive.
1108       *
1109       * It renders in the footer a set of HTML elements to notify users about
1110       * client-side navigations. More concretely, the elements added are 1) a
1111       * top loading bar to visually inform that a navigation is in progress
1112       * and 2) an `aria-live` region for accessible navigation announcements.
1113       *
1114       * @since 6.5.0
1115       *
1116       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
1117       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
1118       */
1119  	private function data_wp_router_region_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
1120          if ( 'enter' === $mode && ! $this->has_processed_router_region ) {
1121              $this->has_processed_router_region = true;
1122  
1123              // Enqueues as an inline style.
1124              wp_register_style( 'wp-interactivity-router-animations', false );
1125              wp_add_inline_style( 'wp-interactivity-router-animations', $this->get_router_animation_styles() );
1126              wp_enqueue_style( 'wp-interactivity-router-animations' );
1127  
1128              // Adds the necessary markup to the footer.
1129              add_action( 'wp_footer', array( $this, 'print_router_markup' ) );
1130          }
1131      }
1132  
1133      /**
1134       * Processes the `data-wp-each` directive.
1135       *
1136       * This directive gets an array passed as reference and iterates over it
1137       * generating new content for each item based on the inner markup of the
1138       * `template` tag.
1139       *
1140       * @since 6.5.0
1141       *
1142       * @param WP_Interactivity_API_Directives_Processor $p               The directives processor instance.
1143       * @param string                                    $mode            Whether the processing is entering or exiting the tag.
1144       * @param array                                     $tag_stack       The reference to the tag stack.
1145       */
1146  	private function data_wp_each_processor( WP_Interactivity_API_Directives_Processor $p, string $mode, array &$tag_stack ) {
1147          if ( 'enter' === $mode && 'TEMPLATE' === $p->get_tag() ) {
1148              $attribute_name   = $p->get_attribute_names_with_prefix( 'data-wp-each' )[0];
1149              $extracted_suffix = $this->extract_prefix_and_suffix( $attribute_name );
1150              $item_name        = isset( $extracted_suffix[1] ) ? $this->kebab_to_camel_case( $extracted_suffix[1] ) : 'item';
1151              $attribute_value  = $p->get_attribute( $attribute_name );
1152              $result           = $this->evaluate( $attribute_value );
1153  
1154              // Gets the content between the template tags and leaves the cursor in the closer tag.
1155              $inner_content = $p->get_content_between_balanced_template_tags();
1156  
1157              // Checks if there is a manual server-side directive processing.
1158              $template_end = 'data-wp-each: template end';
1159              $p->set_bookmark( $template_end );
1160              $p->next_tag();
1161              $manual_sdp = $p->get_attribute( 'data-wp-each-child' );
1162              $p->seek( $template_end ); // Rewinds to the template closer tag.
1163              $p->release_bookmark( $template_end );
1164  
1165              /*
1166               * It doesn't process in these situations:
1167               * - Manual server-side directive processing.
1168               * - Empty or non-array values.
1169               * - Associative arrays because those are deserialized as objects in JS.
1170               * - Templates that contain top-level texts because those texts can't be
1171               *   identified and removed in the client.
1172               */
1173              if (
1174                  $manual_sdp ||
1175                  empty( $result ) ||
1176                  ! is_array( $result ) ||
1177                  ! array_is_list( $result ) ||
1178                  ! str_starts_with( trim( $inner_content ), '<' ) ||
1179                  ! str_ends_with( trim( $inner_content ), '>' )
1180              ) {
1181                  array_pop( $tag_stack );
1182                  return;
1183              }
1184  
1185              // Extracts the namespace from the directive attribute value.
1186              $namespace_value         = end( $this->namespace_stack );
1187              list( $namespace_value ) = is_string( $attribute_value ) && ! empty( $attribute_value )
1188                  ? $this->extract_directive_value( $attribute_value, $namespace_value )
1189                  : array( $namespace_value, null );
1190  
1191              // Processes the inner content for each item of the array.
1192              $processed_content = '';
1193              foreach ( $result as $item ) {
1194                  // Creates a new context that includes the current item of the array.
1195                  $this->context_stack[] = array_replace_recursive(
1196                      end( $this->context_stack ) !== false ? end( $this->context_stack ) : array(),
1197                      array( $namespace_value => array( $item_name => $item ) )
1198                  );
1199  
1200                  // Processes the inner content with the new context.
1201                  $processed_item = $this->_process_directives( $inner_content );
1202  
1203                  if ( null === $processed_item ) {
1204                      // If the HTML is unbalanced, stop processing it.
1205                      array_pop( $this->context_stack );
1206                      return;
1207                  }
1208  
1209                  // Adds the `data-wp-each-child` to each top-level tag.
1210                  $i = new WP_Interactivity_API_Directives_Processor( $processed_item );
1211                  while ( $i->next_tag() ) {
1212                      $i->set_attribute( 'data-wp-each-child', true );
1213                      $i->next_balanced_tag_closer_tag();
1214                  }
1215                  $processed_content .= $i->get_updated_html();
1216  
1217                  // Removes the current context from the stack.
1218                  array_pop( $this->context_stack );
1219              }
1220  
1221              // Appends the processed content after the tag closer of the template.
1222              $p->append_content_after_template_tag_closer( $processed_content );
1223  
1224              // Pops the last tag because it skipped the closing tag of the template tag.
1225              array_pop( $tag_stack );
1226          }
1227      }
1228  }


Generated : Sat Dec 21 08:20:01 2024 Cross-referenced by PHPXref