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