run:R W Run
49.34 KB
2026-03-11 16:18:51
R W Run
4.92 KB
2026-03-11 16:18:51
R W Run
error_log
📄class-wp-interactivity-api.php
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 */
15final 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 ? -1 : 1;
908 }
909 $a_id = $a['unique_id'] ?? '';
910 $b_id = $b['unique_id'] ?? '';
911 if ( $a_id === $b_id ) {
912 return 0;
913 }
914 return $a_id > $b_id ? 1 : -1;
915 }
916 );
917 return $entries;
918 }
919
920 /**
921 * Transforms a kebab-case string to camelCase.
922 *
923 * @since 6.5.0
924 *
925 * @param string $str The kebab-case string to transform to camelCase.
926 * @return string The transformed camelCase string.
927 */
928 private function kebab_to_camel_case( string $str ): string {
929 return lcfirst(
930 preg_replace_callback(
931 '/(-)([a-z])/',
932 function ( $matches ) {
933 return strtoupper( $matches[2] );
934 },
935 strtolower( rtrim( $str, '-' ) )
936 )
937 );
938 }
939
940 /**
941 * Processes the `data-wp-interactive` directive.
942 *
943 * It adds the default store namespace defined in the directive value to the
944 * stack so that it's available for the nested interactivity elements.
945 *
946 * @since 6.5.0
947 *
948 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
949 * @param string $mode Whether the processing is entering or exiting the tag.
950 */
951 private function data_wp_interactive_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
952 // When exiting tags, it removes the last namespace from the stack.
953 if ( 'exit' === $mode ) {
954 array_pop( $this->namespace_stack );
955 return;
956 }
957
958 // Tries to decode the `data-wp-interactive` attribute value.
959 $attribute_value = $p->get_attribute( 'data-wp-interactive' );
960
961 /*
962 * Pushes the newly defined namespace or the current one if the
963 * `data-wp-interactive` definition was invalid or does not contain a
964 * namespace. It does so because the function pops out the current namespace
965 * from the stack whenever it finds a `data-wp-interactive`'s closing tag,
966 * independently of whether the previous `data-wp-interactive` definition
967 * contained a valid namespace.
968 */
969 $new_namespace = null;
970 if ( is_string( $attribute_value ) && ! empty( $attribute_value ) ) {
971 $decoded_json = json_decode( $attribute_value, true );
972 if ( is_array( $decoded_json ) ) {
973 $new_namespace = $decoded_json['namespace'] ?? null;
974 } else {
975 $new_namespace = $attribute_value;
976 }
977 }
978 $this->namespace_stack[] = ( $new_namespace && 1 === preg_match( '/^([\w\-_\/]+)/', $new_namespace ) )
979 ? $new_namespace
980 : end( $this->namespace_stack );
981 }
982
983 /**
984 * Processes the `data-wp-context` directive.
985 *
986 * It adds the context defined in the directive value to the stack so that
987 * it's available for the nested interactivity elements.
988 *
989 * @since 6.5.0
990 *
991 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
992 * @param string $mode Whether the processing is entering or exiting the tag.
993 */
994 private function data_wp_context_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
995 // When exiting tags, it removes the last context from the stack.
996 if ( 'exit' === $mode ) {
997 array_pop( $this->context_stack );
998 return;
999 }
1000
1001 $entries = $this->get_directive_entries( $p, 'context' );
1002 $context = end( $this->context_stack ) !== false ? end( $this->context_stack ) : array();
1003 foreach ( $entries as $entry ) {
1004 if ( null !== $entry['suffix'] ) {
1005 continue;
1006 }
1007
1008 $context = array_replace_recursive(
1009 $context,
1010 array( $entry['namespace'] => is_array( $entry['value'] ) ? $entry['value'] : array() )
1011 );
1012 }
1013 $this->context_stack[] = $context;
1014 }
1015
1016 /**
1017 * Processes the `data-wp-bind` directive.
1018 *
1019 * It updates or removes the bound attributes based on the evaluation of its
1020 * associated reference.
1021 *
1022 * @since 6.5.0
1023 *
1024 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
1025 * @param string $mode Whether the processing is entering or exiting the tag.
1026 */
1027 private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
1028 if ( 'enter' === $mode ) {
1029 $entries = $this->get_directive_entries( $p, 'bind' );
1030 foreach ( $entries as $entry ) {
1031 if ( empty( $entry['suffix'] ) || null !== $entry['unique_id'] ) {
1032 return;
1033 }
1034
1035 // Skip if the suffix is an event handler.
1036 if ( str_starts_with( $entry['suffix'], 'on' ) ) {
1037 _doing_it_wrong(
1038 __METHOD__,
1039 sprintf(
1040 /* translators: %s: The directive, e.g. data-wp-on--click. */
1041 __( 'Binding event handler attributes is not supported. Please use "%s" instead.' ),
1042 esc_attr( 'data-wp-on--' . substr( $entry['suffix'], 2 ) )
1043 ),
1044 '6.9.2'
1045 );
1046 continue;
1047 }
1048
1049 $result = $this->evaluate( $entry );
1050
1051 if (
1052 null !== $result &&
1053 (
1054 false !== $result ||
1055 ( strlen( $entry['suffix'] ) > 5 && '-' === $entry['suffix'][4] )
1056 )
1057 ) {
1058 /*
1059 * If the result of the evaluation is a boolean and the attribute is
1060 * `aria-` or `data-, convert it to a string "true" or "false". It
1061 * follows the exact same logic as Preact because it needs to
1062 * replicate what Preact will later do in the client:
1063 * https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136
1064 */
1065 if (
1066 is_bool( $result ) &&
1067 ( strlen( $entry['suffix'] ) > 5 && '-' === $entry['suffix'][4] )
1068 ) {
1069 $result = $result ? 'true' : 'false';
1070 }
1071 $p->set_attribute( $entry['suffix'], $result );
1072 } else {
1073 $p->remove_attribute( $entry['suffix'] );
1074 }
1075 }
1076 }
1077 }
1078
1079 /**
1080 * Processes the `data-wp-class` directive.
1081 *
1082 * It adds or removes CSS classes in the current HTML element based on the
1083 * evaluation of its associated references.
1084 *
1085 * @since 6.5.0
1086 *
1087 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
1088 * @param string $mode Whether the processing is entering or exiting the tag.
1089 */
1090 private function data_wp_class_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
1091 if ( 'enter' === $mode ) {
1092 $all_class_directives = $p->get_attribute_names_with_prefix( 'data-wp-class--' );
1093 $entries = $this->get_directive_entries( $p, 'class' );
1094 foreach ( $entries as $entry ) {
1095 if ( empty( $entry['suffix'] ) ) {
1096 continue;
1097 }
1098 $class_name = isset( $entry['unique_id'] ) && $entry['unique_id']
1099 ? "{$entry['suffix']}---{$entry['unique_id']}"
1100 : $entry['suffix'];
1101
1102 if ( empty( $class_name ) ) {
1103 return;
1104 }
1105
1106 $result = $this->evaluate( $entry );
1107
1108 if ( $result ) {
1109 $p->add_class( $class_name );
1110 } else {
1111 $p->remove_class( $class_name );
1112 }
1113 }
1114 }
1115 }
1116
1117 /**
1118 * Processes the `data-wp-style` directive.
1119 *
1120 * It updates the style attribute value of the current HTML element based on
1121 * the evaluation of its associated references.
1122 *
1123 * @since 6.5.0
1124 *
1125 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
1126 * @param string $mode Whether the processing is entering or exiting the tag.
1127 */
1128 private function data_wp_style_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
1129 if ( 'enter' === $mode ) {
1130 $entries = $this->get_directive_entries( $p, 'style' );
1131 foreach ( $entries as $entry ) {
1132 $style_property = $entry['suffix'];
1133 if ( empty( $style_property ) || null !== $entry['unique_id'] ) {
1134 continue;
1135 }
1136
1137 $style_property_value = $this->evaluate( $entry );
1138 $style_attribute_value = $p->get_attribute( 'style' );
1139 $style_attribute_value = ( $style_attribute_value && ! is_bool( $style_attribute_value ) ) ? $style_attribute_value : '';
1140
1141 /*
1142 * Checks first if the style property is not falsy and the style
1143 * attribute value is not empty because if it is, it doesn't need to
1144 * update the attribute value.
1145 */
1146 if ( $style_property_value || $style_attribute_value ) {
1147 $style_attribute_value = $this->merge_style_property( $style_attribute_value, $style_property, $style_property_value );
1148 /*
1149 * If the style attribute value is not empty, it sets it. Otherwise,
1150 * it removes it.
1151 */
1152 if ( ! empty( $style_attribute_value ) ) {
1153 $p->set_attribute( 'style', $style_attribute_value );
1154 } else {
1155 $p->remove_attribute( 'style' );
1156 }
1157 }
1158 }
1159 }
1160 }
1161
1162 /**
1163 * Merges an individual style property in the `style` attribute of an HTML
1164 * element, updating or removing the property when necessary.
1165 *
1166 * If a property is modified, the old one is removed and the new one is added
1167 * at the end of the list.
1168 *
1169 * @since 6.5.0
1170 *
1171 * Example:
1172 *
1173 * merge_style_property( 'color:green;', 'color', 'red' ) => 'color:red;'
1174 * merge_style_property( 'background:green;', 'color', 'red' ) => 'background:green;color:red;'
1175 * merge_style_property( 'color:green;', 'color', null ) => ''
1176 *
1177 * @param string $style_attribute_value The current style attribute value.
1178 * @param string $style_property_name The style property name to set.
1179 * @param string|false|null $style_property_value The value to set for the style property. With false, null or an
1180 * empty string, it removes the style property.
1181 * @return string The new style attribute value after the specified property has been added, updated or removed.
1182 */
1183 private function merge_style_property( string $style_attribute_value, string $style_property_name, $style_property_value ): string {
1184 $style_assignments = explode( ';', $style_attribute_value );
1185 $result = array();
1186 $style_property_value = ! empty( $style_property_value ) ? rtrim( trim( $style_property_value ), ';' ) : null;
1187 $new_style_property = $style_property_value ? $style_property_name . ':' . $style_property_value . ';' : '';
1188
1189 // Generates an array with all the properties but the modified one.
1190 foreach ( $style_assignments as $style_assignment ) {
1191 if ( empty( trim( $style_assignment ) ) ) {
1192 continue;
1193 }
1194 list( $name, $value ) = explode( ':', $style_assignment );
1195 if ( trim( $name ) !== $style_property_name ) {
1196 $result[] = trim( $name ) . ':' . trim( $value ) . ';';
1197 }
1198 }
1199
1200 // Adds the new/modified property at the end of the list.
1201 $result[] = $new_style_property;
1202
1203 return implode( '', $result );
1204 }
1205
1206 /**
1207 * Processes the `data-wp-text` directive.
1208 *
1209 * It updates the inner content of the current HTML element based on the
1210 * evaluation of its associated reference.
1211 *
1212 * @since 6.5.0
1213 *
1214 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
1215 * @param string $mode Whether the processing is entering or exiting the tag.
1216 */
1217 private function data_wp_text_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
1218 if ( 'enter' === $mode ) {
1219 $entries = $this->get_directive_entries( $p, 'text' );
1220 $valid_entry = null;
1221 // Get the first valid `data-wp-text` entry without suffix or unique ID.
1222 foreach ( $entries as $entry ) {
1223 if ( null === $entry['suffix'] && null === $entry['unique_id'] && ! empty( $entry['value'] ) ) {
1224 $valid_entry = $entry;
1225 break;
1226 }
1227 }
1228 if ( null === $valid_entry ) {
1229 return;
1230 }
1231 $result = $this->evaluate( $valid_entry );
1232
1233 /*
1234 * Follows the same logic as Preact in the client and only changes the
1235 * content if the value is a string or a number. Otherwise, it removes the
1236 * content.
1237 */
1238 if ( is_string( $result ) || is_numeric( $result ) ) {
1239 $p->set_content_between_balanced_tags( esc_html( $result ) );
1240 } else {
1241 $p->set_content_between_balanced_tags( '' );
1242 }
1243 }
1244 }
1245
1246 /**
1247 * Returns the CSS styles for animating the top loading bar in the router.
1248 *
1249 * @since 6.5.0
1250 *
1251 * @return string The CSS styles for the router's top loading bar animation.
1252 */
1253 private function get_router_animation_styles(): string {
1254 return <<<CSS
1255 .wp-interactivity-router-loading-bar {
1256 position: fixed;
1257 top: 0;
1258 left: 0;
1259 margin: 0;
1260 padding: 0;
1261 width: 100vw;
1262 max-width: 100vw !important;
1263 height: 4px;
1264 background-color: #000;
1265 opacity: 0
1266 }
1267 .wp-interactivity-router-loading-bar.start-animation {
1268 animation: wp-interactivity-router-loading-bar-start-animation 30s cubic-bezier(0.03, 0.5, 0, 1) forwards
1269 }
1270 .wp-interactivity-router-loading-bar.finish-animation {
1271 animation: wp-interactivity-router-loading-bar-finish-animation 300ms ease-in
1272 }
1273 @keyframes wp-interactivity-router-loading-bar-start-animation {
1274 0% { transform: scaleX(0); transform-origin: 0 0; opacity: 1 }
1275 100% { transform: scaleX(1); transform-origin: 0 0; opacity: 1 }
1276 }
1277 @keyframes wp-interactivity-router-loading-bar-finish-animation {
1278 0% { opacity: 1 }
1279 50% { opacity: 1 }
1280 100% { opacity: 0 }
1281 }
1282CSS;
1283 }
1284
1285 /**
1286 * Deprecated.
1287 *
1288 * @since 6.5.0
1289 * @deprecated 6.7.0 Use {@see WP_Interactivity_API::print_router_markup} instead.
1290 */
1291 public function print_router_loading_and_screen_reader_markup() {
1292 _deprecated_function( __METHOD__, '6.7.0', 'WP_Interactivity_API::print_router_markup' );
1293
1294 // Call the new method.
1295 $this->print_router_markup();
1296 }
1297
1298 /**
1299 * Outputs markup for the @wordpress/interactivity-router script module.
1300 *
1301 * This method prints a div element representing a loading bar visible during
1302 * navigation.
1303 *
1304 * @since 6.7.0
1305 */
1306 public function print_router_markup() {
1307 echo <<<HTML
1308 <div
1309 class="wp-interactivity-router-loading-bar"
1310 data-wp-interactive="core/router"
1311 data-wp-class--start-animation="state.navigation.hasStarted"
1312 data-wp-class--finish-animation="state.navigation.hasFinished"
1313 ></div>
1314HTML;
1315 }
1316
1317 /**
1318 * Processes the `data-wp-router-region` directive.
1319 *
1320 * It renders in the footer a set of HTML elements to notify users about
1321 * client-side navigations. More concretely, the elements added are 1) a
1322 * top loading bar to visually inform that a navigation is in progress
1323 * and 2) an `aria-live` region for accessible navigation announcements.
1324 *
1325 * @since 6.5.0
1326 *
1327 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
1328 * @param string $mode Whether the processing is entering or exiting the tag.
1329 */
1330 private function data_wp_router_region_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
1331 if ( 'enter' === $mode && ! $this->has_processed_router_region ) {
1332 $this->has_processed_router_region = true;
1333
1334 // Enqueues as an inline style.
1335 wp_register_style( 'wp-interactivity-router-animations', false );
1336 wp_add_inline_style( 'wp-interactivity-router-animations', $this->get_router_animation_styles() );
1337 wp_enqueue_style( 'wp-interactivity-router-animations' );
1338
1339 // Adds the necessary markup to the footer.
1340 add_action( 'wp_footer', array( $this, 'print_router_markup' ) );
1341 }
1342 }
1343
1344 /**
1345 * Processes the `data-wp-each` directive.
1346 *
1347 * This directive gets an array passed as reference and iterates over it
1348 * generating new content for each item based on the inner markup of the
1349 * `template` tag.
1350 *
1351 * @since 6.5.0
1352 * @since 6.9.0 Include the list path in the rendered `data-wp-each-child` directives.
1353 *
1354 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
1355 * @param string $mode Whether the processing is entering or exiting the tag.
1356 * @param array $tag_stack The reference to the tag stack.
1357 */
1358 private function data_wp_each_processor( WP_Interactivity_API_Directives_Processor $p, string $mode, array &$tag_stack ) {
1359 if ( 'enter' === $mode && 'TEMPLATE' === $p->get_tag() ) {
1360 $entries = $this->get_directive_entries( $p, 'each' );
1361 if ( count( $entries ) > 1 || empty( $entries ) ) {
1362 // There should be only one `data-wp-each` directive per template tag.
1363 return;
1364 }
1365 $entry = $entries[0];
1366 if ( null !== $entry['unique_id'] ) {
1367 return;
1368 }
1369 $item_name = isset( $entry['suffix'] ) ? $this->kebab_to_camel_case( $entry['suffix'] ) : 'item';
1370 $result = $this->evaluate( $entry );
1371
1372 // Gets the content between the template tags and leaves the cursor in the closer tag.
1373 $inner_content = $p->get_content_between_balanced_template_tags();
1374
1375 // Checks if there is a manual server-side directive processing.
1376 $template_end = 'data-wp-each: template end';
1377 $p->set_bookmark( $template_end );
1378 $p->next_tag();
1379 $manual_sdp = $p->get_attribute( 'data-wp-each-child' );
1380 $p->seek( $template_end ); // Rewinds to the template closer tag.
1381 $p->release_bookmark( $template_end );
1382
1383 /*
1384 * It doesn't process in these situations:
1385 * - Manual server-side directive processing.
1386 * - Empty or non-array values.
1387 * - Associative arrays because those are deserialized as objects in JS.
1388 * - Templates that contain top-level texts because those texts can't be
1389 * identified and removed in the client.
1390 */
1391 if (
1392 $manual_sdp ||
1393 empty( $result ) ||
1394 ! is_array( $result ) ||
1395 ! array_is_list( $result ) ||
1396 ! str_starts_with( trim( $inner_content ), '<' ) ||
1397 ! str_ends_with( trim( $inner_content ), '>' )
1398 ) {
1399 array_pop( $tag_stack );
1400 return;
1401 }
1402
1403 // Processes the inner content for each item of the array.
1404 $processed_content = '';
1405 foreach ( $result as $item ) {
1406 // Creates a new context that includes the current item of the array.
1407 $this->context_stack[] = array_replace_recursive(
1408 end( $this->context_stack ) !== false ? end( $this->context_stack ) : array(),
1409 array( $entry['namespace'] => array( $item_name => $item ) )
1410 );
1411
1412 // Processes the inner content with the new context.
1413 $processed_item = $this->_process_directives( $inner_content );
1414
1415 if ( null === $processed_item ) {
1416 // If the HTML is unbalanced, stop processing it.
1417 array_pop( $this->context_stack );
1418 return;
1419 }
1420
1421 /*
1422 * Adds the `data-wp-each-child` directive to each top-level tag
1423 * rendered by this `data-wp-each` directive. The value is the
1424 * `data-wp-each` directive's namespace and path.
1425 *
1426 * Nested `data-wp-each` directives could render
1427 * `data-wp-each-child` elements at the top level as well, and
1428 * they should be overwritten.
1429 *
1430 * @since 6.9.0
1431 */
1432 $i = new WP_Interactivity_API_Directives_Processor( $processed_item );
1433 while ( $i->next_tag() ) {
1434 $i->set_attribute( 'data-wp-each-child', $entry['namespace'] . '::' . $entry['value'] );
1435 $i->next_balanced_tag_closer_tag();
1436 }
1437 $processed_content .= $i->get_updated_html();
1438
1439 // Removes the current context from the stack.
1440 array_pop( $this->context_stack );
1441 }
1442
1443 // Appends the processed content after the tag closer of the template.
1444 $p->append_content_after_template_tag_closer( $processed_content );
1445
1446 // Pops the last tag because it skipped the closing tag of the template tag.
1447 array_pop( $tag_stack );
1448 }
1449 }
1450}
1451
Ui Ux Design – Teachers Night Out

Get in Touch

© 2024 Teachers Night Out. All Rights Reserved.