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