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