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