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