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