| [ 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<string, string> 21 * @phpstan-var array{ 22 * 'data-wp-interactive': 'data_wp_interactive_processor', 23 * 'data-wp-router-region': 'data_wp_router_region_processor', 24 * 'data-wp-context': 'data_wp_context_processor', 25 * 'data-wp-bind': 'data_wp_bind_processor', 26 * 'data-wp-class': 'data_wp_class_processor', 27 * 'data-wp-style': 'data_wp_style_processor', 28 * 'data-wp-text': 'data_wp_text_processor', 29 * 'data-wp-each': 'data_wp_each_processor', 30 * } 31 */ 32 private static array $directive_processors = array( 33 'data-wp-interactive' => 'data_wp_interactive_processor', 34 'data-wp-router-region' => 'data_wp_router_region_processor', 35 'data-wp-context' => 'data_wp_context_processor', 36 'data-wp-bind' => 'data_wp_bind_processor', 37 'data-wp-class' => 'data_wp_class_processor', 38 'data-wp-style' => 'data_wp_style_processor', 39 'data-wp-text' => 'data_wp_text_processor', 40 /* 41 * `data-wp-each` needs to be processed in the last place because it moves 42 * the cursor to the end of the processed items to prevent them to be 43 * processed twice. 44 */ 45 'data-wp-each' => 'data_wp_each_processor', 46 ); 47 48 /** 49 * Holds the initial state of the different Interactivity API stores. 50 * 51 * This state is used during the server directive processing. Then, it is 52 * serialized and sent to the client as part of the interactivity data to be 53 * recovered during the hydration of the client interactivity stores. 54 * 55 * @since 6.5.0 56 * @var array 57 */ 58 private $state_data = array(); 59 60 /** 61 * Holds the configuration required by the different Interactivity API stores. 62 * 63 * This configuration is serialized and sent to the client as part of the 64 * interactivity data and can be accessed by the client interactivity stores. 65 * 66 * @since 6.5.0 67 * @var array 68 */ 69 private $config_data = array(); 70 71 /** 72 * Keeps track of all derived state closures accessed during server-side rendering. 73 * 74 * This data is serialized and sent to the client as part of the interactivity 75 * data, and is handled later in the client to support derived state props that 76 * are lazily hydrated. 77 * 78 * @since 6.9.0 79 * @var array 80 */ 81 private $derived_state_closures = array(); 82 83 /** 84 * Flag that indicates whether the `data-wp-router-region` directive has 85 * been found in the HTML and processed. 86 * 87 * The value is saved in a private property of the WP_Interactivity_API 88 * instance instead of using a static variable inside the processor 89 * function, which would hold the same value for all instances 90 * independently of whether they have processed any 91 * `data-wp-router-region` directive or not. 92 * 93 * @since 6.5.0 94 * @var bool 95 */ 96 private $has_processed_router_region = false; 97 98 /** 99 * Set of script modules that can be loaded after client-side navigation. 100 * 101 * @since 6.9.0 102 * @var array<string, true> 103 */ 104 private $script_modules_that_can_load_on_client_navigation = array(); 105 106 /** 107 * Stack of namespaces defined by `data-wp-interactive` directives, in 108 * the order they are processed. 109 * 110 * This is only available during directive processing, otherwise it is `null`. 111 * 112 * An entry is the namespace the directive defined. It is `false` instead when 113 * the directive did not define a usable one — the attribute was empty, or its 114 * JSON held no `namespace`, or the namespace did not match the accepted 115 * characters — and no enclosing `data-wp-interactive` was in effect to inherit 116 * from. An entry is pushed either way, because one is popped for every closing 117 * tag regardless of what the directive contained, so `false` is what stands in 118 * for "no namespace here" and keeps the stack balanced. 119 * 120 * @since 6.6.0 121 * @var array<string|false>|null 122 * @phpstan-var list<string|false>|null 123 */ 124 private $namespace_stack = null; 125 126 /** 127 * Stack of contexts defined by `data-wp-context` directives, in 128 * the order they are processed. 129 * 130 * This is only available during directive processing, otherwise it is `null`. 131 * 132 * @since 6.6.0 133 * @var array<array<mixed>>|null 134 */ 135 private $context_stack = null; 136 137 /** 138 * Representation in array format of the element currently being processed. 139 * 140 * This is only available during directive processing, otherwise it is `null`. 141 * 142 * @since 6.7.0 143 * @var array{attributes: array<string, string|bool>}|null 144 */ 145 private $current_element = null; 146 147 /** 148 * Gets and/or sets the initial state of an Interactivity API store for a 149 * given namespace. 150 * 151 * If state for that store namespace already exists, it merges the new 152 * provided state with the existing one. 153 * 154 * When no namespace is specified, it returns the state defined for the 155 * current value in the internal namespace stack during a `process_directives` call. 156 * 157 * @since 6.5.0 158 * @since 6.6.0 The `$store_namespace` param is optional. 159 * 160 * @param string|null $store_namespace Optional. The unique store namespace identifier. 161 * @param array|null $state Optional. The array that will be merged with the existing state for the specified 162 * store namespace. 163 * @return array The current state for the specified store namespace. This will be the updated state if a $state 164 * argument was provided. 165 */ 166 public function state( ?string $store_namespace = null, ?array $state = null ): array { 167 if ( ! $store_namespace ) { 168 if ( $state ) { 169 _doing_it_wrong( 170 __METHOD__, 171 __( 'The namespace is required when state data is passed.' ), 172 '6.6.0' 173 ); 174 return array(); 175 } 176 if ( null !== $store_namespace ) { 177 _doing_it_wrong( 178 __METHOD__, 179 __( 'The namespace should be a non-empty string.' ), 180 '6.6.0' 181 ); 182 return array(); 183 } 184 if ( null === $this->namespace_stack ) { 185 _doing_it_wrong( 186 __METHOD__, 187 __( 'The namespace can only be omitted during directive processing.' ), 188 '6.6.0' 189 ); 190 return array(); 191 } 192 193 $store_namespace = end( $this->namespace_stack ); 194 } 195 if ( ! isset( $this->state_data[ $store_namespace ] ) ) { 196 $this->state_data[ $store_namespace ] = array(); 197 } 198 if ( is_array( $state ) ) { 199 $this->state_data[ $store_namespace ] = array_replace_recursive( 200 $this->state_data[ $store_namespace ], 201 $state 202 ); 203 } 204 return $this->state_data[ $store_namespace ]; 205 } 206 207 /** 208 * Gets and/or sets the configuration of the Interactivity API for a given 209 * store namespace. 210 * 211 * If configuration for that store namespace exists, it merges the new 212 * provided configuration with the existing one. 213 * 214 * @since 6.5.0 215 * 216 * @param string $store_namespace The unique store namespace identifier. 217 * @param array $config Optional. The array that will be merged with the existing configuration for the 218 * specified store namespace. 219 * @return array The configuration for the specified store namespace. This will be the updated configuration if a 220 * $config argument was provided. 221 */ 222 public function config( string $store_namespace, array $config = array() ): array { 223 if ( ! isset( $this->config_data[ $store_namespace ] ) ) { 224 $this->config_data[ $store_namespace ] = array(); 225 } 226 if ( is_array( $config ) ) { 227 $this->config_data[ $store_namespace ] = array_replace_recursive( 228 $this->config_data[ $store_namespace ], 229 $config 230 ); 231 } 232 return $this->config_data[ $store_namespace ]; 233 } 234 235 /** 236 * Prints the serialized client-side interactivity data. 237 * 238 * Encodes the config and initial state into JSON and prints them inside a 239 * script tag of type "application/json". Once in the browser, the state will 240 * be parsed and used to hydrate the client-side interactivity stores and the 241 * configuration will be available using a `getConfig` utility. 242 * 243 * @since 6.5.0 244 * 245 * @deprecated 6.7.0 Client data passing is handled by the {@see "script_module_data_{$module_id}"} filter. 246 */ 247 public function print_client_interactivity_data() { 248 _deprecated_function( __METHOD__, '6.7.0' ); 249 } 250 251 /** 252 * Set client-side interactivity-router data. 253 * 254 * Once in the browser, the state will be parsed and used to hydrate the client-side 255 * interactivity stores and the configuration will be available using a `getConfig` utility. 256 * 257 * @since 6.7.0 258 * 259 * @param array $data Data to filter. 260 * @return array Data for the Interactivity Router script module. 261 */ 262 public function filter_script_module_interactivity_router_data( array $data ): array { 263 if ( ! isset( $data['i18n'] ) ) { 264 $data['i18n'] = array(); 265 } 266 $data['i18n']['loading'] = __( 'Loading page, please wait.' ); 267 $data['i18n']['loaded'] = __( 'Page Loaded.' ); 268 return $data; 269 } 270 271 /** 272 * Set client-side interactivity data. 273 * 274 * Once in the browser, the state will be parsed and used to hydrate the client-side 275 * interactivity stores and the configuration will be available using a `getConfig` utility. 276 * 277 * @since 6.7.0 278 * @since 6.9.0 Serializes derived state props accessed during directive processing. 279 * 280 * @param array $data Data to filter. 281 * @return array Data for the Interactivity API script module. 282 */ 283 public function filter_script_module_interactivity_data( array $data ): array { 284 if ( 285 empty( $this->state_data ) && 286 empty( $this->config_data ) && 287 empty( $this->derived_state_closures ) 288 ) { 289 return $data; 290 } 291 292 $config = array(); 293 foreach ( $this->config_data as $key => $value ) { 294 if ( ! empty( $value ) ) { 295 $config[ $key ] = $value; 296 } 297 } 298 if ( ! empty( $config ) ) { 299 $data['config'] = $config; 300 } 301 302 $state = array(); 303 foreach ( $this->state_data as $key => $value ) { 304 if ( ! empty( $value ) ) { 305 $state[ $key ] = $value; 306 } 307 } 308 if ( ! empty( $state ) ) { 309 $data['state'] = $state; 310 } 311 312 $derived_props = array(); 313 foreach ( $this->derived_state_closures as $key => $value ) { 314 if ( ! empty( $value ) ) { 315 $derived_props[ $key ] = $value; 316 } 317 } 318 if ( ! empty( $derived_props ) ) { 319 $data['derivedStateClosures'] = $derived_props; 320 } 321 322 return $data; 323 } 324 325 /** 326 * Returns the latest value on the context stack with the passed namespace. 327 * 328 * When the namespace is omitted, it uses the current namespace on the 329 * namespace stack during a `process_directives` call. 330 * 331 * @since 6.6.0 332 * 333 * @param string|null $store_namespace Optional. The unique store namespace identifier. 334 */ 335 public function get_context( ?string $store_namespace = null ): array { 336 if ( null === $this->context_stack ) { 337 _doing_it_wrong( 338 __METHOD__, 339 __( 'The context can only be read during directive processing.' ), 340 '6.6.0' 341 ); 342 return array(); 343 } 344 345 if ( ! $store_namespace ) { 346 if ( null !== $store_namespace ) { 347 _doing_it_wrong( 348 __METHOD__, 349 __( 'The namespace should be a non-empty string.' ), 350 '6.6.0' 351 ); 352 return array(); 353 } 354 355 $store_namespace = end( $this->namespace_stack ); 356 } 357 358 $context = end( $this->context_stack ); 359 360 return ( $store_namespace && $context && isset( $context[ $store_namespace ] ) ) 361 ? $context[ $store_namespace ] 362 : array(); 363 } 364 365 /** 366 * Returns an array representation of the current element being processed. 367 * 368 * The returned array contains a copy of the element attributes. 369 * 370 * @since 6.7.0 371 * 372 * @return array{attributes: array<string, string|bool>}|null Current element. 373 */ 374 public function get_element(): ?array { 375 if ( null === $this->current_element ) { 376 _doing_it_wrong( 377 __METHOD__, 378 __( 'The element can only be read during directive processing.' ), 379 '6.7.0' 380 ); 381 } 382 383 return $this->current_element; 384 } 385 386 /** 387 * Registers the `@wordpress/interactivity` script modules. 388 * 389 * @deprecated 6.7.0 Script Modules registration is handled by {@see wp_default_script_modules()}. 390 * 391 * @since 6.5.0 392 */ 393 public function register_script_modules() { 394 _deprecated_function( __METHOD__, '6.7.0', 'wp_default_script_modules' ); 395 } 396 397 /** 398 * Adds the necessary hooks for the Interactivity API. 399 * 400 * @since 6.5.0 401 * @since 6.9.0 Adds support for client-side navigation in script modules. 402 */ 403 public function add_hooks() { 404 add_filter( 'script_module_data_@wordpress/interactivity', array( $this, 'filter_script_module_interactivity_data' ) ); 405 add_filter( 'script_module_data_@wordpress/interactivity-router', array( $this, 'filter_script_module_interactivity_router_data' ) ); 406 add_filter( 'wp_script_attributes', array( $this, 'add_load_on_client_navigation_attribute_to_script_modules' ) ); 407 } 408 409 /** 410 * Adds the `data-wp-router-options` attribute to script modules that 411 * support client-side navigation. 412 * 413 * This method filters the script attributes to include loading instructions 414 * for the Interactivity API router, indicating which modules can be loaded 415 * during client-side navigation. 416 * 417 * @since 6.9.0 418 * 419 * @param array<string, string|true>|mixed $attributes The script tag attributes. 420 * @return array The modified script tag attributes. 421 */ 422 public function add_load_on_client_navigation_attribute_to_script_modules( $attributes ) { 423 if ( 424 is_array( $attributes ) && 425 isset( $attributes['type'], $attributes['id'] ) && 426 'module' === $attributes['type'] && 427 array_key_exists( 428 preg_replace( '/-js-module$/', '', $attributes['id'] ), 429 $this->script_modules_that_can_load_on_client_navigation 430 ) 431 ) { 432 $attributes['data-wp-router-options'] = wp_json_encode( array( 'loadOnClientNavigation' => true ) ); 433 } 434 return $attributes; 435 } 436 437 /** 438 * Marks a script module as compatible with client-side navigation. 439 * 440 * This method registers a script module to be loaded during client-side 441 * navigation in the Interactivity API router. Script modules marked with 442 * this method will have the `loadOnClientNavigation` option enabled in the 443 * `data-wp-router-options` directive. 444 * 445 * @since 6.9.0 446 * 447 * @param string $script_module_id The script module identifier. 448 */ 449 public function add_client_navigation_support_to_script_module( string $script_module_id ) { 450 $this->script_modules_that_can_load_on_client_navigation[ $script_module_id ] = true; 451 } 452 453 /** 454 * Processes the interactivity directives contained within the HTML content 455 * and updates the markup accordingly. 456 * 457 * @since 6.5.0 458 * 459 * @param string $html The HTML content to process. 460 * @return string The processed HTML content. It returns the original content when the HTML contains unbalanced tags. 461 */ 462 public function process_directives( string $html ): string { 463 if ( ! str_contains( $html, 'data-wp-' ) ) { 464 return $html; 465 } 466 467 $this->namespace_stack = array(); 468 $this->context_stack = array(); 469 470 $result = $this->_process_directives( $html ); 471 472 $this->namespace_stack = null; 473 $this->context_stack = null; 474 475 return $result ?? $html; 476 } 477 478 /** 479 * Processes the interactivity directives contained within the HTML content 480 * and updates the markup accordingly. 481 * 482 * It uses the WP_Interactivity_API instance's context and namespace stacks, 483 * which are shared between all calls. 484 * 485 * This method returns null if the HTML contains unbalanced tags. 486 * 487 * @since 6.6.0 488 * 489 * @param string $html The HTML content to process. 490 * @return string|null The processed HTML content. It returns null when the HTML contains unbalanced tags. 491 */ 492 private function _process_directives( string $html ) { 493 $p = new WP_Interactivity_API_Directives_Processor( $html ); 494 $tag_stack = array(); 495 $unbalanced = false; 496 497 $directive_processor_prefixes = array_keys( self::$directive_processors ); 498 $directive_processor_prefixes_reversed = array_reverse( $directive_processor_prefixes ); 499 500 /* 501 * Save the current size for each stack to restore them in case 502 * the processing finds unbalanced tags. 503 */ 504 $namespace_stack_size = count( $this->namespace_stack ); 505 $context_stack_size = count( $this->context_stack ); 506 507 while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) { 508 $tag_name = $p->get_tag(); 509 510 /* 511 * Directives inside SVG and MATH tags are not processed, 512 * as they are not compatible with the Tag Processor yet. 513 * We still process the rest of the HTML. 514 */ 515 if ( 'SVG' === $tag_name || 'MATH' === $tag_name ) { 516 if ( $p->get_attribute_names_with_prefix( 'data-wp-' ) ) { 517 /* translators: 1: SVG or MATH HTML tag, 2: Namespace of the interactive block. */ 518 $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 ) ); 519 _doing_it_wrong( __METHOD__, $message, '6.6.0' ); 520 } 521 $p->skip_to_tag_closer(); 522 continue; 523 } 524 525 if ( $p->is_tag_closer() ) { 526 list( $opening_tag_name, $directives_prefixes ) = ! empty( $tag_stack ) ? end( $tag_stack ) : array( null, null ); 527 528 if ( 0 === count( $tag_stack ) || $opening_tag_name !== $tag_name ) { 529 530 /* 531 * If the tag stack is empty or the matching opening tag is not the 532 * same than the closing tag, it means the HTML is unbalanced and it 533 * stops processing it. 534 */ 535 $unbalanced = true; 536 break; 537 } else { 538 // Remove the last tag from the stack. 539 array_pop( $tag_stack ); 540 } 541 } else { 542 $each_child_attrs = $p->get_attribute_names_with_prefix( 'data-wp-each-child' ); 543 if ( null === $each_child_attrs ) { 544 continue; 545 } 546 547 if ( 0 !== count( $each_child_attrs ) ) { 548 /* 549 * If the tag has a `data-wp-each-child` directive, jump to its closer 550 * tag because those tags have already been processed. 551 */ 552 $p->next_balanced_tag_closer_tag(); 553 continue; 554 } else { 555 $directives_prefixes = array(); 556 557 // Checks if there is a server directive processor registered for each directive. 558 foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) { 559 $parsed_directive = $this->parse_directive_name( $attribute_name ); 560 if ( empty( $parsed_directive ) ) { 561 continue; 562 } 563 $directive_prefix = 'data-wp-' . $parsed_directive['prefix']; 564 if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) { 565 $directives_prefixes[] = $directive_prefix; 566 } 567 } 568 569 /* 570 * If this tag will visit its closer tag, it adds it to the tag stack 571 * so it can process its closing tag and check for unbalanced tags. 572 */ 573 if ( $p->has_and_visits_its_closer_tag() ) { 574 $tag_stack[] = array( $tag_name, $directives_prefixes ); 575 } 576 } 577 } 578 /* 579 * If the matching opener tag didn't have any directives, it can skip the 580 * processing. 581 */ 582 if ( 0 === count( $directives_prefixes ) ) { 583 continue; 584 } 585 586 // Directive processing might be different depending on if it is entering the tag or exiting it. 587 $modes = array( 588 'enter' => ! $p->is_tag_closer(), 589 'exit' => $p->is_tag_closer() || ! $p->has_and_visits_its_closer_tag(), 590 ); 591 592 // Get the element attributes to include them in the element representation. 593 $element_attrs = array(); 594 $attr_names = $p->get_attribute_names_with_prefix( '' ) ?? array(); 595 596 foreach ( $attr_names as $name ) { 597 $element_attrs[ $name ] = $p->get_attribute( $name ); 598 } 599 600 // Assign the current element right before running its directive processors. 601 $this->current_element = array( 602 'attributes' => $element_attrs, 603 ); 604 605 foreach ( $modes as $mode => $should_run ) { 606 if ( ! $should_run ) { 607 continue; 608 } 609 610 /* 611 * Sorts the attributes by the order of the `directives_processor` array 612 * and checks what directives are present in this element. 613 */ 614 $existing_directives_prefixes = array_intersect( 615 'enter' === $mode ? $directive_processor_prefixes : $directive_processor_prefixes_reversed, 616 $directives_prefixes 617 ); 618 foreach ( $existing_directives_prefixes as $directive_prefix ) { 619 $func = is_array( self::$directive_processors[ $directive_prefix ] ) 620 ? self::$directive_processors[ $directive_prefix ] 621 : array( $this, self::$directive_processors[ $directive_prefix ] ); 622 623 call_user_func_array( $func, array( $p, $mode, &$tag_stack ) ); 624 } 625 } 626 627 // Clear the current element. 628 $this->current_element = null; 629 } 630 631 if ( $unbalanced ) { 632 // Reset the namespace and context stacks to their previous values. 633 array_splice( $this->namespace_stack, $namespace_stack_size ); 634 array_splice( $this->context_stack, $context_stack_size ); 635 } 636 637 /* 638 * It returns null if the HTML is unbalanced because unbalanced HTML is 639 * not safe to process. In that case, the Interactivity API runtime will 640 * update the HTML on the client side during the hydration. It will display 641 * a notice to the developer in the console to inform them about the issue. 642 */ 643 if ( $unbalanced || 0 < count( $tag_stack ) ) { 644 return null; 645 } 646 647 return $p->get_updated_html(); 648 } 649 650 /** 651 * Evaluates the reference path passed to a directive based on the current 652 * store namespace, state and context. 653 * 654 * @since 6.5.0 655 * @since 6.6.0 The function now adds a warning when the namespace is null, falsy, or the directive value is empty. 656 * @since 6.6.0 Removed `default_namespace` and `context` arguments. 657 * @since 6.6.0 Add support for derived state. 658 * @since 6.9.0 Receive $entry as an argument instead of the directive value string. 659 * 660 * @param array $entry An array containing a whole directive entry with its namespace, value, suffix, or unique ID. 661 * @return mixed|null The result of the evaluation. Null if the reference path doesn't exist or the namespace is falsy. 662 */ 663 private function evaluate( $entry ) { 664 $context = end( $this->context_stack ); 665 ['namespace' => $ns, 'value' => $path] = $entry; 666 667 if ( ! $ns || ! $path ) { 668 /* translators: %s: The directive value referenced. */ 669 $message = sprintf( __( 'Namespace or reference path cannot be empty. Directive value referenced: %s' ), json_encode( $entry ) ); 670 _doing_it_wrong( __METHOD__, $message, '6.6.0' ); 671 return null; 672 } 673 674 $store = array( 675 'state' => $this->state_data[ $ns ] ?? array(), 676 'context' => $context[ $ns ] ?? array(), 677 ); 678 679 // Checks if the reference path is preceded by a negation operator (!). 680 $should_negate_value = '!' === $path[0]; 681 $path = $should_negate_value ? substr( $path, 1 ) : $path; 682 683 // Extracts the value from the store using the reference path. 684 $path_segments = explode( '.', $path ); 685 $current = $store; 686 foreach ( $path_segments as $index => $path_segment ) { 687 /* 688 * Special case for numeric arrays and strings. Add length 689 * property mimicking JavaScript behavior. 690 * 691 * @since 6.8.0 692 */ 693 if ( 'length' === $path_segment ) { 694 if ( is_array( $current ) && array_is_list( $current ) ) { 695 $current = count( $current ); 696 break; 697 } 698 699 if ( is_string( $current ) ) { 700 /* 701 * Differences in encoding between PHP strings and 702 * JavaScript mean that it's complicated to calculate 703 * the string length JavaScript would see from PHP. 704 * `strlen` is a reasonable approximation. 705 * 706 * Users that desire a more precise length likely have 707 * more precise needs than "bytelength" and should 708 * implement their own length calculation in derived 709 * state taking into account encoding and their desired 710 * output (codepoints, graphemes, bytes, etc.). 711 */ 712 $current = strlen( $current ); 713 break; 714 } 715 } 716 717 if ( ( is_array( $current ) || $current instanceof ArrayAccess ) && isset( $current[ $path_segment ] ) ) { 718 $current = $current[ $path_segment ]; 719 } elseif ( is_object( $current ) && isset( $current->$path_segment ) ) { 720 $current = $current->$path_segment; 721 } else { 722 $current = null; 723 break; 724 } 725 726 if ( $current instanceof Closure ) { 727 /* 728 * This state getter's namespace is added to the stack so that 729 * `state()` or `get_config()` read that namespace when called 730 * without specifying one. 731 */ 732 array_push( $this->namespace_stack, $ns ); 733 try { 734 $current = $current(); 735 736 /* 737 * Tracks derived state properties that are accessed during 738 * rendering. 739 * 740 * @since 6.9.0 741 */ 742 $this->derived_state_closures[ $ns ] = $this->derived_state_closures[ $ns ] ?? array(); 743 744 // Builds path for the current property and add it to tracking if not already present. 745 $current_path = implode( '.', array_slice( $path_segments, 0, $index + 1 ) ); 746 if ( ! in_array( $current_path, $this->derived_state_closures[ $ns ], true ) ) { 747 $this->derived_state_closures[ $ns ][] = $current_path; 748 } 749 } catch ( Throwable $e ) { 750 _doing_it_wrong( 751 __METHOD__, 752 sprintf( 753 /* translators: 1: Path pointing to an Interactivity API state property, 2: Namespace for an Interactivity API store. */ 754 __( 'Uncaught error executing a derived state callback with path "%1$s" and namespace "%2$s".' ), 755 $path, 756 $ns 757 ), 758 '6.6.0' 759 ); 760 return null; 761 } finally { 762 // Remove the property's namespace from the stack. 763 array_pop( $this->namespace_stack ); 764 } 765 } 766 } 767 768 // Returns the opposite if it contains a negation operator (!). 769 return $should_negate_value ? ! $current : $current; 770 } 771 772 /** 773 * Parse the directive name to extract the following parts: 774 * - Prefix: The main directive name without "data-wp-". It cannot begin with a hyphen. 775 * - Suffix: An optional suffix used during directive processing, extracted after the first double hyphen "--". 776 * - Unique ID: An optional unique identifier, extracted after the first triple hyphen "---". 777 * 778 * This function has an equivalent version for the client side. 779 * See `parseDirectiveName` in https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/src/vdom.ts: 780 * 781 * An empty suffix or unique ID is normalized to null, but the string "0" is preserved. The 782 * client's `|| null` discards only the empty string, since every non-empty string is truthy in 783 * JavaScript. Do not use empty() for these checks: it would discard "0" and diverge from the 784 * client. 785 * 786 * @see Tests_Interactivity_API_WpInteractivityAPI::test_parse_directive_name() for examples in the test inputs. 787 * 788 * @since 6.9.0 789 * 790 * @param string $directive_name The directive attribute name. 791 * @return array|null An array containing the directive prefix, optional suffix, and optional unique ID, or null if the directive name cannot be parsed. 792 * @phpstan-return array{ 793 * prefix: non-empty-string, 794 * suffix: non-empty-string|null, 795 * unique_id: non-empty-string|null, 796 * }|null 797 */ 798 private function parse_directive_name( string $directive_name ): ?array { 799 // Remove the first 8 characters (assumes "data-wp-" prefix) 800 $name = (string) substr( $directive_name, 8 ); 801 802 // Ensure the name only contains valid characters (anything a-z, A-Z, 0-9, -, or _). 803 if ( 1 !== preg_match( '/^[a-zA-Z0-9\-_]+$/', $name ) ) { 804 return null; 805 } 806 807 // Find the first occurrence of '--' to separate the prefix. 808 $suffix_index = strpos( $name, '--' ); 809 810 /* 811 * A prefix cannot begin with a hyphen, so a name which does is not a directive at all. This 812 * covers both a lone leading hyphen, as in "data-wp--bind", and a leading double hyphen, as 813 * in "data-wp---foo", where treating the hyphens as a suffix separator would instead leave 814 * the prefix empty. It also covers "data-wp----unique-id", where only a unique ID is supplied 815 * without any prefix or suffix. 816 */ 817 if ( 0 === $suffix_index || '-' === $name[0] ) { 818 return null; 819 } 820 821 // Without a '--' the whole name is the prefix. (This naturally also means there is no unique ID after '---'.) 822 if ( false === $suffix_index ) { 823 return array( 824 'prefix' => $name, 825 'suffix' => null, 826 'unique_id' => null, 827 ); 828 } 829 830 $prefix = substr( $name, 0, $suffix_index ); 831 $remaining = substr( $name, $suffix_index ); 832 833 // If remaining starts with '---' but not '----', it's a unique_id 834 if ( 3 === strspn( $remaining, '-' ) ) { 835 $unique_id = (string) substr( $remaining, 3 ); 836 return array( 837 'prefix' => $prefix, 838 'suffix' => null, 839 'unique_id' => '' === $unique_id ? null : $unique_id, 840 ); 841 } 842 843 // Otherwise, remove the first two dashes for a potential suffix 844 $suffix = (string) substr( $remaining, 2 ); 845 846 // Look for '---' in the suffix for a unique_id 847 $unique_id_index = strpos( $suffix, '---' ); 848 849 if ( false !== $unique_id_index && '-' !== ( $suffix[ $unique_id_index + 3 ] ?? '' ) ) { 850 $unique_id = (string) substr( $suffix, $unique_id_index + 3 ); 851 $suffix = (string) substr( $suffix, 0, $unique_id_index ); 852 return array( 853 'prefix' => $prefix, 854 'suffix' => '' === $suffix ? null : $suffix, 855 'unique_id' => '' === $unique_id ? null : $unique_id, 856 ); 857 } 858 859 return array( 860 'prefix' => $prefix, 861 'suffix' => '' === $suffix ? null : $suffix, 862 'unique_id' => null, 863 ); 864 } 865 866 /** 867 * Parses and extracts the namespace and reference path from the given 868 * directive attribute value. 869 * 870 * If the value doesn't contain an explicit namespace, it returns the 871 * default one. If the value contains a JSON object instead of a reference 872 * path, the function tries to parse it and return the resulting array. If 873 * the value contains strings that represent booleans ("true" and "false"), 874 * numbers ("1" and "1.2") or "null", the function also transform them to 875 * regular booleans, numbers and `null`. 876 * 877 * Example: 878 * 879 * extract_directive_value( 'actions.foo', 'myPlugin' ) => array( 'myPlugin', 'actions.foo' ) 880 * extract_directive_value( 'otherPlugin::actions.foo', 'myPlugin' ) => array( 'otherPlugin', 'actions.foo' ) 881 * extract_directive_value( '{ "isOpen": false }', 'myPlugin' ) => array( 'myPlugin', array( 'isOpen' => false ) ) 882 * extract_directive_value( 'otherPlugin::{ "isOpen": false }', 'myPlugin' ) => array( 'otherPlugin', array( 'isOpen' => false ) ) 883 * 884 * @since 6.5.0 885 * 886 * @param string|true $directive_value The directive attribute value. It can be `true` when it's a boolean 887 * attribute. 888 * @param string|null $default_namespace Optional. The default namespace if none is explicitly defined. 889 * @return array An array containing the namespace in the first item and the JSON, the reference path, or null on the 890 * second item. 891 * @phpstan-return array{ 0: string|null, 1: mixed } 892 */ 893 private function extract_directive_value( $directive_value, $default_namespace = null ): array { 894 if ( empty( $directive_value ) || is_bool( $directive_value ) ) { 895 return array( $default_namespace, null ); 896 } 897 898 // Replaces the value and namespace if there is a namespace in the value. 899 if ( 1 === preg_match( '/^([\w\-_\/]+)::./', $directive_value ) ) { 900 list($default_namespace, $directive_value) = explode( '::', $directive_value, 2 ); 901 } 902 903 /* 904 * Tries to decode the value as a JSON object. If it fails and the value 905 * isn't `null`, it returns the value as it is. Otherwise, it returns the 906 * decoded JSON or null for the string `null`. 907 */ 908 $decoded_json = json_decode( $directive_value, true ); 909 if ( null !== $decoded_json || 'null' === $directive_value ) { 910 $directive_value = $decoded_json; 911 } 912 913 return array( $default_namespace, $directive_value ); 914 } 915 916 /** 917 * Parse the HTML element and get all the valid directives with the given prefix. 918 * 919 * @since 6.9.0 920 * 921 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. 922 * @param string $prefix The directive prefix to filter by. 923 * @return array An array of entries containing the directive namespace, value, suffix, and unique ID. 924 * @phpstan-return list<array{ 925 * namespace: string|null, 926 * value: mixed, 927 * suffix: string|null, 928 * unique_id: string|null, 929 * }> 930 */ 931 private function get_directive_entries( WP_Interactivity_API_Directives_Processor $p, string $prefix ): array { 932 $directive_attributes = $p->get_attribute_names_with_prefix( 'data-wp-' . $prefix ); 933 if ( null === $directive_attributes ) { 934 return array(); 935 } 936 937 $entries = array(); 938 foreach ( $directive_attributes as $attribute_name ) { 939 $parsed_directive = $this->parse_directive_name( $attribute_name ); 940 if ( null === $parsed_directive ) { 941 continue; 942 } 943 944 [ 'prefix' => $attr_prefix, 'suffix' => $suffix, 'unique_id' => $unique_id ] = $parsed_directive; 945 // Ensure it is the desired directive. 946 if ( $prefix !== $attr_prefix ) { 947 continue; 948 } 949 $attribute_value = $p->get_attribute( $attribute_name ); 950 if ( null === $attribute_value ) { 951 continue; 952 } 953 /* 954 * The namespace stack can hold false, which data_wp_interactive_processor() pushes for a 955 * `data-wp-interactive` whose namespace is invalid and which has no enclosing one to inherit. Only a 956 * string names a store, so anything else counts as no default namespace at all. 957 */ 958 $default_namespace = array_last( $this->namespace_stack ?? array() ); 959 if ( ! is_string( $default_namespace ) ) { 960 $default_namespace = null; 961 } 962 963 list( $namespace, $value ) = $this->extract_directive_value( $attribute_value, $default_namespace ); 964 $entries[] = array( 965 'namespace' => $namespace, 966 'value' => $value, 967 'suffix' => $suffix, 968 'unique_id' => $unique_id, 969 ); 970 } 971 // Sort directive entries to ensure stable ordering with the client. 972 // Put nulls first, then sort by suffix and finally by uniqueIds. 973 usort( 974 $entries, 975 function ( $a, $b ) { 976 $a_suffix = $a['suffix'] ?? ''; 977 $b_suffix = $b['suffix'] ?? ''; 978 if ( $a_suffix !== $b_suffix ) { 979 return $a_suffix <=> $b_suffix; 980 } 981 $a_id = $a['unique_id'] ?? ''; 982 $b_id = $b['unique_id'] ?? ''; 983 return $a_id <=> $b_id; 984 } 985 ); 986 return $entries; 987 } 988 989 /** 990 * Transforms a kebab-case string to camelCase. 991 * 992 * @since 6.5.0 993 * 994 * @param string $str The kebab-case string to transform to camelCase. 995 * @return string The transformed camelCase string. 996 */ 997 private function kebab_to_camel_case( string $str ): string { 998 return lcfirst( 999 preg_replace_callback( 1000 '/(-)([a-z])/', 1001 function ( $matches ) { 1002 return strtoupper( $matches[2] ); 1003 }, 1004 strtolower( rtrim( $str, '-' ) ) 1005 ) 1006 ); 1007 } 1008 1009 /** 1010 * Processes the `data-wp-interactive` directive. 1011 * 1012 * It adds the default store namespace defined in the directive value to the 1013 * stack so that it's available for the nested interactivity elements. 1014 * 1015 * @since 6.5.0 1016 * 1017 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. 1018 * @param string $mode Whether the processing is entering or exiting the tag. 1019 */ 1020 private function data_wp_interactive_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) { 1021 // When exiting tags, it removes the last namespace from the stack. 1022 if ( 'exit' === $mode ) { 1023 array_pop( $this->namespace_stack ); 1024 return; 1025 } 1026 1027 // Tries to decode the `data-wp-interactive` attribute value. 1028 $attribute_value = $p->get_attribute( 'data-wp-interactive' ); 1029 1030 /* 1031 * Pushes the newly defined namespace or the current one if the 1032 * `data-wp-interactive` definition was invalid or does not contain a 1033 * namespace. It does so because the function pops out the current namespace 1034 * from the stack whenever it finds a `data-wp-interactive`'s closing tag, 1035 * independently of whether the previous `data-wp-interactive` definition 1036 * contained a valid namespace. 1037 */ 1038 $new_namespace = null; 1039 if ( is_string( $attribute_value ) && ! empty( $attribute_value ) ) { 1040 $decoded_json = json_decode( $attribute_value, true ); 1041 if ( is_array( $decoded_json ) ) { 1042 $new_namespace = $decoded_json['namespace'] ?? null; 1043 } else { 1044 $new_namespace = $attribute_value; 1045 } 1046 } 1047 $this->namespace_stack[] = ( $new_namespace && 1 === preg_match( '/^([\w\-_\/]+)/', $new_namespace ) ) 1048 ? $new_namespace 1049 : end( $this->namespace_stack ); 1050 } 1051 1052 /** 1053 * Processes the `data-wp-context` directive. 1054 * 1055 * It adds the context defined in the directive value to the stack so that 1056 * it's available for the nested interactivity elements. 1057 * 1058 * @since 6.5.0 1059 * 1060 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. 1061 * @param string $mode Whether the processing is entering or exiting the tag. 1062 */ 1063 private function data_wp_context_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) { 1064 // When exiting tags, it removes the last context from the stack. 1065 if ( 'exit' === $mode ) { 1066 array_pop( $this->context_stack ); 1067 return; 1068 } 1069 1070 $entries = $this->get_directive_entries( $p, 'context' ); 1071 $context = end( $this->context_stack ) !== false ? end( $this->context_stack ) : array(); 1072 foreach ( $entries as $entry ) { 1073 if ( null !== $entry['suffix'] ) { 1074 continue; 1075 } 1076 1077 /* 1078 * A context with no namespace has nothing to be stored under, so the inherited context is left as it 1079 * is. Using the namespace as an array key regardless would coerce null to an empty string, which PHP 1080 * 8.5 deprecates, and would store the context where no reference can address it anyway. 1081 */ 1082 if ( null === $entry['namespace'] ) { 1083 continue; 1084 } 1085 1086 $context = array_replace_recursive( 1087 $context, 1088 array( $entry['namespace'] => is_array( $entry['value'] ) ? $entry['value'] : array() ) 1089 ); 1090 } 1091 $this->context_stack[] = $context; 1092 } 1093 1094 /** 1095 * Processes the `data-wp-bind` directive. 1096 * 1097 * It updates or removes the bound attributes based on the evaluation of its 1098 * associated reference. 1099 * 1100 * @since 6.5.0 1101 * @since 7.1.0 An object is resolved to whatever it serializes to for the client, a number is formatted by the 1102 * JSON encoder, and a value which cannot be sent to the client is rejected rather than passed to 1103 * WP_HTML_Tag_Processor::set_attribute(). 1104 * 1105 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. 1106 * @param string $mode Whether the processing is entering or exiting the tag. 1107 */ 1108 private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ): void { 1109 if ( 'enter' === $mode ) { 1110 $entries = $this->get_directive_entries( $p, 'bind' ); 1111 foreach ( $entries as $entry ) { 1112 if ( empty( $entry['suffix'] ) || null !== $entry['unique_id'] ) { 1113 continue; 1114 } 1115 1116 // Skip if the suffix is an event handler. 1117 if ( str_starts_with( $entry['suffix'], 'on' ) ) { 1118 _doing_it_wrong( 1119 __METHOD__, 1120 sprintf( 1121 /* translators: %s: The directive, e.g. data-wp-on--click. */ 1122 __( 'Binding event handler attributes is not supported. Please use "%s" instead.' ), 1123 esc_attr( 'data-wp-on--' . substr( $entry['suffix'], 2 ) ) 1124 ), 1125 '6.9.2' 1126 ); 1127 continue; 1128 } 1129 1130 $result = $this->evaluate( $entry ); 1131 1132 /* 1133 * An object is resolved to whatever it serializes to. When the reference points to a value stored 1134 * in state or context, that is the value the client receives for it when the store is hydrated. 1135 * A derived state closure is never serialized, so there the client value comes from the derived 1136 * state's client-side implementation instead; the resolution is still applied so that both origins 1137 * behave the same. Round-tripping through the JSON encoder rather than calling 1138 * JsonSerializable::jsonSerialize() directly keeps this resolution identical to the client's, 1139 * including for an object which serializes to another serializable object. When the encoding fails 1140 * the object is left in place, to be reported as a usage error below. Note that it rarely does 1141 * fail: wp_json_encode() retries through _wp_json_sanity_check(), which rebuilds the object from 1142 * its public properties and so ignores jsonSerialize() altogether. An object whose serialized form 1143 * JSON cannot represent therefore resolves to whatever that rebuild encodes to, which is what the 1144 * client is sent for it as well. 1145 * 1146 * A throwing JsonSerializable::jsonSerialize() is caught for the same reason the value is checked 1147 * at all: a binding must not be able to abort the render. An exception escaping here would leave 1148 * `$context_stack` and `$namespace_stack` unrestored for every later `process_directives()` call 1149 * on this instance, so the object is treated as one which failed to encode. 1150 */ 1151 if ( is_object( $result ) ) { 1152 try { 1153 $encoded = wp_json_encode( $result ); 1154 } catch ( Throwable $e ) { 1155 $encoded = false; 1156 } 1157 if ( false !== $encoded ) { 1158 $result = json_decode( $encoded ); 1159 } 1160 } 1161 1162 /* 1163 * Only a value which can be sent to the client may be stored in an attribute value. Strings and 1164 * booleans are passed in as-is, numbers are formatted, and everything else is rejected as a usage 1165 * error. 1166 * 1167 * An object which does not serialize to a scalar is rejected even when it defines `__toString()`, 1168 * which PHP would otherwise coerce for the string parameters of the escaping functions. Its string 1169 * representation is not what the client evaluates this reference to, whether that is the form 1170 * serialized into the store or the return value of a derived state's client-side implementation, 1171 * so the two could disagree once the directive is evaluated during hydration. 1172 */ 1173 if ( null !== $result ) { 1174 if ( ! is_scalar( $result ) ) { 1175 _doing_it_wrong( 1176 __METHOD__, 1177 sprintf( 1178 /* translators: %s: The attribute name. */ 1179 __( 'Attempted to bind a non-scalar value to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a string, number, or boolean.' ), 1180 esc_html( $entry['suffix'] ) 1181 ), 1182 '7.1.0' 1183 ); 1184 $result = null; 1185 } elseif ( is_int( $result ) || is_float( $result ) ) { 1186 /* 1187 * A number is formatted by the JSON encoder rather than cast to string, so that the 1188 * attribute value matches the number the client receives for this same reference. Casting 1189 * a float is locale-dependent before PHP 8.0, and rounds to `precision` rather than to the 1190 * encoder's `serialize_precision`. 1191 * 1192 * This closes the cases which differ in practice, not every one. A float written in 1193 * exponent notation still disagrees, since PHP encodes 1e25 as `1.0e+25` where JavaScript 1194 * renders it as `1e+25`, as does negative zero, and an integer above the range JavaScript 1195 * can represent exactly is rounded once it reaches the client. Casting diverged on all 1196 * three as well, so none is a regression. 1197 */ 1198 $encoded = wp_json_encode( $result ); 1199 if ( JSON_ERROR_INF_OR_NAN === json_last_error() ) { 1200 /* 1201 * The encoder only rejects INF and NAN, of which JSON can represent neither. When such 1202 * a value is stored in state, the store itself also fails to encode in its entirety, 1203 * and the client is sent an empty script tag in place of all of its state; only 1204 * removing the value from the state resolves that. A derived state closure returning 1205 * one never reaches the store, so there only the binding itself is affected. 1206 */ 1207 _doing_it_wrong( 1208 __METHOD__, 1209 sprintf( 1210 /* translators: %s: The attribute name. */ 1211 __( 'Attempted to bind a non-finite number to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a finite number or a string.' ), 1212 esc_html( $entry['suffix'] ) 1213 ), 1214 '7.1.0' 1215 ); 1216 $result = null; 1217 } else { 1218 $result = $encoded; 1219 } 1220 } 1221 } 1222 1223 if ( 1224 null !== $result && 1225 ( 1226 false !== $result || 1227 ( strlen( $entry['suffix'] ) > 5 && '-' === $entry['suffix'][4] ) 1228 ) 1229 ) { 1230 /* 1231 * If the result of the evaluation is a boolean and the attribute is 1232 * `aria-` or `data-, convert it to a string "true" or "false". It 1233 * follows the exact same logic as Preact because it needs to 1234 * replicate what Preact will later do in the client: 1235 * https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 1236 */ 1237 if ( 1238 is_bool( $result ) && 1239 ( strlen( $entry['suffix'] ) > 5 && '-' === $entry['suffix'][4] ) 1240 ) { 1241 $result = $result ? 'true' : 'false'; 1242 } 1243 $p->set_attribute( $entry['suffix'], $result ); 1244 } else { 1245 $p->remove_attribute( $entry['suffix'] ); 1246 } 1247 } 1248 } 1249 } 1250 1251 /** 1252 * Processes the `data-wp-class` directive. 1253 * 1254 * It adds or removes CSS classes in the current HTML element based on the 1255 * evaluation of its associated references. 1256 * 1257 * @since 6.5.0 1258 * 1259 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. 1260 * @param string $mode Whether the processing is entering or exiting the tag. 1261 */ 1262 private function data_wp_class_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) { 1263 if ( 'enter' === $mode ) { 1264 $entries = $this->get_directive_entries( $p, 'class' ); 1265 foreach ( $entries as $entry ) { 1266 if ( empty( $entry['suffix'] ) ) { 1267 continue; 1268 } 1269 $class_name = isset( $entry['unique_id'] ) && $entry['unique_id'] 1270 ? "{$entry['suffix']}---{$entry['unique_id']}" 1271 : $entry['suffix']; 1272 1273 if ( empty( $class_name ) ) { 1274 return; 1275 } 1276 1277 $result = $this->evaluate( $entry ); 1278 1279 if ( $result ) { 1280 $p->add_class( $class_name ); 1281 } else { 1282 $p->remove_class( $class_name ); 1283 } 1284 } 1285 } 1286 } 1287 1288 /** 1289 * Processes the `data-wp-style` directive. 1290 * 1291 * It updates the style attribute value of the current HTML element based on 1292 * the evaluation of its associated references. 1293 * 1294 * @since 6.5.0 1295 * 1296 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. 1297 * @param string $mode Whether the processing is entering or exiting the tag. 1298 */ 1299 private function data_wp_style_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) { 1300 if ( 'enter' === $mode ) { 1301 $entries = $this->get_directive_entries( $p, 'style' ); 1302 foreach ( $entries as $entry ) { 1303 $style_property = $entry['suffix']; 1304 if ( empty( $style_property ) || null !== $entry['unique_id'] ) { 1305 continue; 1306 } 1307 1308 $style_property_value = $this->evaluate( $entry ); 1309 $style_attribute_value = $p->get_attribute( 'style' ); 1310 $style_attribute_value = ( $style_attribute_value && ! is_bool( $style_attribute_value ) ) ? $style_attribute_value : ''; 1311 1312 /* 1313 * Checks first if the style property is not falsy and the style 1314 * attribute value is not empty because if it is, it doesn't need to 1315 * update the attribute value. 1316 */ 1317 if ( $style_property_value || $style_attribute_value ) { 1318 $style_attribute_value = $this->merge_style_property( $style_attribute_value, $style_property, $style_property_value ); 1319 /* 1320 * If the style attribute value is not empty, it sets it. Otherwise, 1321 * it removes it. 1322 */ 1323 if ( ! empty( $style_attribute_value ) ) { 1324 $p->set_attribute( 'style', $style_attribute_value ); 1325 } else { 1326 $p->remove_attribute( 'style' ); 1327 } 1328 } 1329 } 1330 } 1331 } 1332 1333 /** 1334 * Merges an individual style property in the `style` attribute of an HTML 1335 * element, updating or removing the property when necessary. 1336 * 1337 * If a property is modified, the old one is removed and the new one is added 1338 * at the end of the list. 1339 * 1340 * @since 6.5.0 1341 * 1342 * Example: 1343 * 1344 * merge_style_property( 'color:green;', 'color', 'red' ) => 'color:red;' 1345 * merge_style_property( 'background:green;', 'color', 'red' ) => 'background:green;color:red;' 1346 * merge_style_property( 'color:green;', 'color', null ) => '' 1347 * 1348 * @param string $style_attribute_value The current style attribute value. 1349 * @param string $style_property_name The style property name to set. 1350 * @param string|false|null $style_property_value The value to set for the style property. With false, null or an 1351 * empty string, it removes the style property. 1352 * @return string The new style attribute value after the specified property has been added, updated or removed. 1353 */ 1354 private function merge_style_property( string $style_attribute_value, string $style_property_name, $style_property_value ): string { 1355 $style_assignments = explode( ';', $style_attribute_value ); 1356 $result = array(); 1357 $style_property_value = ! empty( $style_property_value ) ? rtrim( trim( $style_property_value ), ';' ) : null; 1358 $new_style_property = $style_property_value ? $style_property_name . ':' . $style_property_value . ';' : ''; 1359 1360 // Generates an array with all the properties but the modified one. 1361 foreach ( $style_assignments as $style_assignment ) { 1362 if ( empty( trim( $style_assignment ) ) ) { 1363 continue; 1364 } 1365 list( $name, $value ) = explode( ':', $style_assignment ); 1366 if ( trim( $name ) !== $style_property_name ) { 1367 $result[] = trim( $name ) . ':' . trim( $value ) . ';'; 1368 } 1369 } 1370 1371 // Adds the new/modified property at the end of the list. 1372 $result[] = $new_style_property; 1373 1374 return implode( '', $result ); 1375 } 1376 1377 /** 1378 * Processes the `data-wp-text` directive. 1379 * 1380 * It updates the inner content of the current HTML element based on the 1381 * evaluation of its associated reference. 1382 * 1383 * @since 6.5.0 1384 * 1385 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. 1386 * @param string $mode Whether the processing is entering or exiting the tag. 1387 */ 1388 private function data_wp_text_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) { 1389 if ( 'enter' === $mode ) { 1390 $entries = $this->get_directive_entries( $p, 'text' ); 1391 $valid_entry = null; 1392 // Get the first valid `data-wp-text` entry without suffix or unique ID. 1393 foreach ( $entries as $entry ) { 1394 if ( null === $entry['suffix'] && null === $entry['unique_id'] && ! empty( $entry['value'] ) ) { 1395 $valid_entry = $entry; 1396 break; 1397 } 1398 } 1399 if ( null === $valid_entry ) { 1400 return; 1401 } 1402 $result = $this->evaluate( $valid_entry ); 1403 1404 /* 1405 * Follows the same logic as Preact in the client and only changes the 1406 * content if the value is a string or a number. Otherwise, it removes the 1407 * content. 1408 */ 1409 if ( is_string( $result ) || is_numeric( $result ) ) { 1410 $p->set_content_between_balanced_tags( esc_html( $result ) ); 1411 } else { 1412 $p->set_content_between_balanced_tags( '' ); 1413 } 1414 } 1415 } 1416 1417 /** 1418 * Returns the CSS styles for animating the top loading bar in the router. 1419 * 1420 * @since 6.5.0 1421 * 1422 * @return string The CSS styles for the router's top loading bar animation. 1423 */ 1424 private function get_router_animation_styles(): string { 1425 return <<<CSS 1426 .wp-interactivity-router-loading-bar { 1427 position: fixed; 1428 top: 0; 1429 left: 0; 1430 margin: 0; 1431 padding: 0; 1432 width: 100vw; 1433 max-width: 100vw !important; 1434 height: 4px; 1435 background-color: #000; 1436 opacity: 0 1437 } 1438 .wp-interactivity-router-loading-bar.start-animation { 1439 animation: wp-interactivity-router-loading-bar-start-animation 30s cubic-bezier(0.03, 0.5, 0, 1) forwards 1440 } 1441 .wp-interactivity-router-loading-bar.finish-animation { 1442 animation: wp-interactivity-router-loading-bar-finish-animation 300ms ease-in 1443 } 1444 @keyframes wp-interactivity-router-loading-bar-start-animation { 1445 0% { transform: scaleX(0); transform-origin: 0 0; opacity: 1 } 1446 100% { transform: scaleX(1); transform-origin: 0 0; opacity: 1 } 1447 } 1448 @keyframes wp-interactivity-router-loading-bar-finish-animation { 1449 0% { opacity: 1 } 1450 50% { opacity: 1 } 1451 100% { opacity: 0 } 1452 } 1453 CSS; 1454 } 1455 1456 /** 1457 * Deprecated. 1458 * 1459 * @since 6.5.0 1460 * @deprecated 6.7.0 Use {@see WP_Interactivity_API::print_router_markup} instead. 1461 */ 1462 public function print_router_loading_and_screen_reader_markup() { 1463 _deprecated_function( __METHOD__, '6.7.0', 'WP_Interactivity_API::print_router_markup' ); 1464 1465 // Call the new method. 1466 $this->print_router_markup(); 1467 } 1468 1469 /** 1470 * Outputs markup for the @wordpress/interactivity-router script module. 1471 * 1472 * This method prints a div element representing a loading bar visible during 1473 * navigation. 1474 * 1475 * @since 6.7.0 1476 */ 1477 public function print_router_markup() { 1478 echo <<<HTML 1479 <div 1480 class="wp-interactivity-router-loading-bar" 1481 data-wp-interactive="core/router/private" 1482 data-wp-class--start-animation="state.navigation.hasStarted" 1483 data-wp-class--finish-animation="state.navigation.hasFinished" 1484 ></div> 1485 HTML; 1486 } 1487 1488 /** 1489 * Processes the `data-wp-router-region` directive. 1490 * 1491 * It renders in the footer a set of HTML elements to notify users about 1492 * client-side navigations. More concretely, the elements added are 1) a 1493 * top loading bar to visually inform that a navigation is in progress 1494 * and 2) an `aria-live` region for accessible navigation announcements. 1495 * 1496 * @since 6.5.0 1497 * 1498 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. 1499 * @param string $mode Whether the processing is entering or exiting the tag. 1500 */ 1501 private function data_wp_router_region_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) { 1502 if ( 'enter' === $mode && ! $this->has_processed_router_region ) { 1503 $this->has_processed_router_region = true; 1504 1505 // Initializes the `state.url` property from the server. 1506 $this->state( 1507 'core/router', 1508 array( 1509 'url' => get_self_link(), 1510 ) 1511 ); 1512 1513 // Enqueues as an inline style. 1514 wp_register_style( 'wp-interactivity-router-animations', false ); 1515 wp_add_inline_style( 'wp-interactivity-router-animations', $this->get_router_animation_styles() ); 1516 wp_enqueue_style( 'wp-interactivity-router-animations' ); 1517 1518 // Adds the necessary markup to the footer. 1519 add_action( 'wp_footer', array( $this, 'print_router_markup' ) ); 1520 } 1521 } 1522 1523 /** 1524 * Processes the `data-wp-each` directive. 1525 * 1526 * This directive gets an array passed as reference and iterates over it 1527 * generating new content for each item based on the inner markup of the 1528 * `template` tag. 1529 * 1530 * @since 6.5.0 1531 * @since 6.9.0 Include the list path in the rendered `data-wp-each-child` directives. 1532 * 1533 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. 1534 * @param string $mode Whether the processing is entering or exiting the tag. 1535 * @param array $tag_stack The reference to the tag stack. 1536 */ 1537 private function data_wp_each_processor( WP_Interactivity_API_Directives_Processor $p, string $mode, array &$tag_stack ) { 1538 if ( 'enter' === $mode && 'TEMPLATE' === $p->get_tag() ) { 1539 $entries = $this->get_directive_entries( $p, 'each' ); 1540 if ( count( $entries ) > 1 || empty( $entries ) ) { 1541 // There should be only one `data-wp-each` directive per template tag. 1542 return; 1543 } 1544 $entry = $entries[0]; 1545 if ( null !== $entry['unique_id'] ) { 1546 return; 1547 } 1548 $item_name = isset( $entry['suffix'] ) ? $this->kebab_to_camel_case( $entry['suffix'] ) : 'item'; 1549 $result = $this->evaluate( $entry ); 1550 1551 // Gets the content between the template tags and leaves the cursor in the closer tag. 1552 $inner_content = $p->get_content_between_balanced_template_tags(); 1553 1554 // Checks if there is a manual server-side directive processing. 1555 $template_end = 'data-wp-each: template end'; 1556 $p->set_bookmark( $template_end ); 1557 $p->next_tag(); 1558 $manual_sdp = $p->get_attribute( 'data-wp-each-child' ); 1559 $p->seek( $template_end ); // Rewinds to the template closer tag. 1560 $p->release_bookmark( $template_end ); 1561 1562 /* 1563 * It doesn't process in these situations: 1564 * - Manual server-side directive processing. 1565 * - Empty or non-array values. 1566 * - Associative arrays because those are deserialized as objects in JS. 1567 * - Templates that contain top-level texts because those texts can't be 1568 * identified and removed in the client. 1569 */ 1570 if ( 1571 $manual_sdp || 1572 empty( $result ) || 1573 ! is_array( $result ) || 1574 ! array_is_list( $result ) || 1575 ! str_starts_with( trim( $inner_content ), '<' ) || 1576 ! str_ends_with( trim( $inner_content ), '>' ) 1577 ) { 1578 array_pop( $tag_stack ); 1579 return; 1580 } 1581 1582 // Processes the inner content for each item of the array. 1583 $processed_content = ''; 1584 foreach ( $result as $item ) { 1585 // Creates a new context that includes the current item of the array. 1586 $this->context_stack[] = array_replace_recursive( 1587 end( $this->context_stack ) !== false ? end( $this->context_stack ) : array(), 1588 array( $entry['namespace'] => array( $item_name => $item ) ) 1589 ); 1590 1591 // Processes the inner content with the new context. 1592 $processed_item = $this->_process_directives( $inner_content ); 1593 1594 if ( null === $processed_item ) { 1595 // If the HTML is unbalanced, stop processing it. 1596 array_pop( $this->context_stack ); 1597 return; 1598 } 1599 1600 /* 1601 * Adds the `data-wp-each-child` directive to each top-level tag 1602 * rendered by this `data-wp-each` directive. The value is the 1603 * `data-wp-each` directive's namespace and path. 1604 * 1605 * Nested `data-wp-each` directives could render 1606 * `data-wp-each-child` elements at the top level as well, and 1607 * they should be overwritten. 1608 * 1609 * @since 6.9.0 1610 */ 1611 $i = new WP_Interactivity_API_Directives_Processor( $processed_item ); 1612 while ( $i->next_tag() ) { 1613 $i->set_attribute( 'data-wp-each-child', $entry['namespace'] . '::' . $entry['value'] ); 1614 $i->next_balanced_tag_closer_tag(); 1615 } 1616 $processed_content .= $i->get_updated_html(); 1617 1618 // Removes the current context from the stack. 1619 array_pop( $this->context_stack ); 1620 } 1621 1622 // Appends the processed content after the tag closer of the template. 1623 $p->append_content_after_template_tag_closer( $processed_content ); 1624 1625 // Pops the last tag because it skipped the closing tag of the template tag. 1626 array_pop( $tag_stack ); 1627 } 1628 } 1629 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated : Fri Jul 31 08:20:18 2026 | Cross-referenced by PHPXref |