[ 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 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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated : Sat Dec 21 08:20:01 2024 | Cross-referenced by PHPXref |