|
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 * Flag that indicates whether the `data-wp-router-region` directive has |
|
63 * been found in the HTML and processed. |
|
64 * |
|
65 * The value is saved in a private property of the WP_Interactivity_API |
|
66 * instance instead of using a static variable inside the processor |
|
67 * function, which would hold the same value for all instances |
|
68 * independently of whether they have processed any |
|
69 * `data-wp-router-region` directive or not. |
|
70 * |
|
71 * @since 6.5.0 |
|
72 * @var bool |
|
73 */ |
|
74 private $has_processed_router_region = false; |
|
75 |
|
76 /** |
|
77 * Stack of namespaces defined by `data-wp-interactive` directives, in |
|
78 * the order they are processed. |
|
79 * |
|
80 * This is only available during directive processing, otherwise it is `null`. |
|
81 * |
|
82 * @since 6.6.0 |
|
83 * @var array<string>|null |
|
84 */ |
|
85 private $namespace_stack = null; |
|
86 |
|
87 /** |
|
88 * Stack of contexts defined by `data-wp-context` directives, in |
|
89 * the order they are processed. |
|
90 * |
|
91 * This is only available during directive processing, otherwise it is `null`. |
|
92 * |
|
93 * @since 6.6.0 |
|
94 * @var array<array<mixed>>|null |
|
95 */ |
|
96 private $context_stack = null; |
|
97 |
|
98 /** |
|
99 * Gets and/or sets the initial state of an Interactivity API store for a |
|
100 * given namespace. |
|
101 * |
|
102 * If state for that store namespace already exists, it merges the new |
|
103 * provided state with the existing one. |
|
104 * |
|
105 * When no namespace is specified, it returns the state defined for the |
|
106 * current value in the internal namespace stack during a `process_directives` call. |
|
107 * |
|
108 * @since 6.5.0 |
|
109 * @since 6.6.0 The `$store_namespace` param is optional. |
|
110 * |
|
111 * @param string $store_namespace Optional. The unique store namespace identifier. |
|
112 * @param array $state Optional. The array that will be merged with the existing state for the specified |
|
113 * store namespace. |
|
114 * @return array The current state for the specified store namespace. This will be the updated state if a $state |
|
115 * argument was provided. |
|
116 */ |
|
117 public function state( ?string $store_namespace = null, ?array $state = null ): array { |
|
118 if ( ! $store_namespace ) { |
|
119 if ( $state ) { |
|
120 _doing_it_wrong( |
|
121 __METHOD__, |
|
122 __( 'The namespace is required when state data is passed.' ), |
|
123 '6.6.0' |
|
124 ); |
|
125 return array(); |
|
126 } |
|
127 if ( null !== $store_namespace ) { |
|
128 _doing_it_wrong( |
|
129 __METHOD__, |
|
130 __( 'The namespace should be a non-empty string.' ), |
|
131 '6.6.0' |
|
132 ); |
|
133 return array(); |
|
134 } |
|
135 if ( null === $this->namespace_stack ) { |
|
136 _doing_it_wrong( |
|
137 __METHOD__, |
|
138 __( 'The namespace can only be omitted during directive processing.' ), |
|
139 '6.6.0' |
|
140 ); |
|
141 return array(); |
|
142 } |
|
143 |
|
144 $store_namespace = end( $this->namespace_stack ); |
|
145 } |
|
146 if ( ! isset( $this->state_data[ $store_namespace ] ) ) { |
|
147 $this->state_data[ $store_namespace ] = array(); |
|
148 } |
|
149 if ( is_array( $state ) ) { |
|
150 $this->state_data[ $store_namespace ] = array_replace_recursive( |
|
151 $this->state_data[ $store_namespace ], |
|
152 $state |
|
153 ); |
|
154 } |
|
155 return $this->state_data[ $store_namespace ]; |
|
156 } |
|
157 |
|
158 /** |
|
159 * Gets and/or sets the configuration of the Interactivity API for a given |
|
160 * store namespace. |
|
161 * |
|
162 * If configuration for that store namespace exists, it merges the new |
|
163 * provided configuration with the existing one. |
|
164 * |
|
165 * @since 6.5.0 |
|
166 * |
|
167 * @param string $store_namespace The unique store namespace identifier. |
|
168 * @param array $config Optional. The array that will be merged with the existing configuration for the |
|
169 * specified store namespace. |
|
170 * @return array The configuration for the specified store namespace. This will be the updated configuration if a |
|
171 * $config argument was provided. |
|
172 */ |
|
173 public function config( string $store_namespace, array $config = array() ): array { |
|
174 if ( ! isset( $this->config_data[ $store_namespace ] ) ) { |
|
175 $this->config_data[ $store_namespace ] = array(); |
|
176 } |
|
177 if ( is_array( $config ) ) { |
|
178 $this->config_data[ $store_namespace ] = array_replace_recursive( |
|
179 $this->config_data[ $store_namespace ], |
|
180 $config |
|
181 ); |
|
182 } |
|
183 return $this->config_data[ $store_namespace ]; |
|
184 } |
|
185 |
|
186 /** |
|
187 * Prints the serialized client-side interactivity data. |
|
188 * |
|
189 * Encodes the config and initial state into JSON and prints them inside a |
|
190 * script tag of type "application/json". Once in the browser, the state will |
|
191 * be parsed and used to hydrate the client-side interactivity stores and the |
|
192 * configuration will be available using a `getConfig` utility. |
|
193 * |
|
194 * @since 6.5.0 |
|
195 */ |
|
196 public function print_client_interactivity_data() { |
|
197 if ( empty( $this->state_data ) && empty( $this->config_data ) ) { |
|
198 return; |
|
199 } |
|
200 |
|
201 $interactivity_data = array(); |
|
202 |
|
203 $config = array(); |
|
204 foreach ( $this->config_data as $key => $value ) { |
|
205 if ( ! empty( $value ) ) { |
|
206 $config[ $key ] = $value; |
|
207 } |
|
208 } |
|
209 if ( ! empty( $config ) ) { |
|
210 $interactivity_data['config'] = $config; |
|
211 } |
|
212 |
|
213 $state = array(); |
|
214 foreach ( $this->state_data as $key => $value ) { |
|
215 if ( ! empty( $value ) ) { |
|
216 $state[ $key ] = $value; |
|
217 } |
|
218 } |
|
219 if ( ! empty( $state ) ) { |
|
220 $interactivity_data['state'] = $state; |
|
221 } |
|
222 |
|
223 if ( ! empty( $interactivity_data ) ) { |
|
224 /* |
|
225 * This data will be printed as JSON inside a script tag like this: |
|
226 * <script type="application/json"></script> |
|
227 * |
|
228 * A script tag must be closed by a sequence beginning with `</`. It's impossible to |
|
229 * close a script tag without using `<`. We ensure that `<` is escaped and `/` can |
|
230 * remain unescaped, so `</script>` will be printed as `\u003C/script\u00E3`. |
|
231 * |
|
232 * - JSON_HEX_TAG: All < and > are converted to \u003C and \u003E. |
|
233 * - JSON_UNESCAPED_SLASHES: Don't escape /. |
|
234 * |
|
235 * If the page will use UTF-8 encoding, it's safe to print unescaped unicode: |
|
236 * |
|
237 * - JSON_UNESCAPED_UNICODE: Encode multibyte Unicode characters literally (instead of as `\uXXXX`). |
|
238 * - JSON_UNESCAPED_LINE_TERMINATORS: The line terminators are kept unescaped when |
|
239 * JSON_UNESCAPED_UNICODE is supplied. It uses the same behaviour as it was |
|
240 * before PHP 7.1 without this constant. Available as of PHP 7.1.0. |
|
241 * |
|
242 * The JSON specification requires encoding in UTF-8, so if the generated HTML page |
|
243 * is not encoded in UTF-8 then it's not safe to include those literals. They must |
|
244 * be escaped to avoid encoding issues. |
|
245 * |
|
246 * @see https://www.rfc-editor.org/rfc/rfc8259.html for details on encoding requirements. |
|
247 * @see https://www.php.net/manual/en/json.constants.php for details on these constants. |
|
248 * @see https://html.spec.whatwg.org/#script-data-state for details on script tag parsing. |
|
249 */ |
|
250 $json_encode_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_LINE_TERMINATORS; |
|
251 if ( ! is_utf8_charset() ) { |
|
252 $json_encode_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES; |
|
253 } |
|
254 |
|
255 wp_print_inline_script_tag( |
|
256 wp_json_encode( |
|
257 $interactivity_data, |
|
258 $json_encode_flags |
|
259 ), |
|
260 array( |
|
261 'type' => 'application/json', |
|
262 'id' => 'wp-interactivity-data', |
|
263 ) |
|
264 ); |
|
265 } |
|
266 } |
|
267 |
|
268 /** |
|
269 * Returns the latest value on the context stack with the passed namespace. |
|
270 * |
|
271 * When the namespace is omitted, it uses the current namespace on the |
|
272 * namespace stack during a `process_directives` call. |
|
273 * |
|
274 * @since 6.6.0 |
|
275 * |
|
276 * @param string $store_namespace Optional. The unique store namespace identifier. |
|
277 */ |
|
278 public function get_context( ?string $store_namespace = null ): array { |
|
279 if ( null === $this->context_stack ) { |
|
280 _doing_it_wrong( |
|
281 __METHOD__, |
|
282 __( 'The context can only be read during directive processing.' ), |
|
283 '6.6.0' |
|
284 ); |
|
285 return array(); |
|
286 } |
|
287 |
|
288 if ( ! $store_namespace ) { |
|
289 if ( null !== $store_namespace ) { |
|
290 _doing_it_wrong( |
|
291 __METHOD__, |
|
292 __( 'The namespace should be a non-empty string.' ), |
|
293 '6.6.0' |
|
294 ); |
|
295 return array(); |
|
296 } |
|
297 |
|
298 $store_namespace = end( $this->namespace_stack ); |
|
299 } |
|
300 |
|
301 $context = end( $this->context_stack ); |
|
302 |
|
303 return ( $store_namespace && $context && isset( $context[ $store_namespace ] ) ) |
|
304 ? $context[ $store_namespace ] |
|
305 : array(); |
|
306 } |
|
307 |
|
308 /** |
|
309 * Registers the `@wordpress/interactivity` script modules. |
|
310 * |
|
311 * @since 6.5.0 |
|
312 */ |
|
313 public function register_script_modules() { |
|
314 $suffix = wp_scripts_get_suffix(); |
|
315 |
|
316 wp_register_script_module( |
|
317 '@wordpress/interactivity', |
|
318 includes_url( "js/dist/interactivity$suffix.js" ) |
|
319 ); |
|
320 |
|
321 wp_register_script_module( |
|
322 '@wordpress/interactivity-router', |
|
323 includes_url( "js/dist/interactivity-router$suffix.js" ), |
|
324 array( '@wordpress/interactivity' ) |
|
325 ); |
|
326 } |
|
327 |
|
328 /** |
|
329 * Adds the necessary hooks for the Interactivity API. |
|
330 * |
|
331 * @since 6.5.0 |
|
332 */ |
|
333 public function add_hooks() { |
|
334 add_action( 'wp_enqueue_scripts', array( $this, 'register_script_modules' ) ); |
|
335 add_action( 'wp_footer', array( $this, 'print_client_interactivity_data' ) ); |
|
336 |
|
337 add_action( 'admin_enqueue_scripts', array( $this, 'register_script_modules' ) ); |
|
338 add_action( 'admin_print_footer_scripts', array( $this, 'print_client_interactivity_data' ) ); |
|
339 } |
|
340 |
|
341 /** |
|
342 * Processes the interactivity directives contained within the HTML content |
|
343 * and updates the markup accordingly. |
|
344 * |
|
345 * @since 6.5.0 |
|
346 * |
|
347 * @param string $html The HTML content to process. |
|
348 * @return string The processed HTML content. It returns the original content when the HTML contains unbalanced tags. |
|
349 */ |
|
350 public function process_directives( string $html ): string { |
|
351 if ( ! str_contains( $html, 'data-wp-' ) ) { |
|
352 return $html; |
|
353 } |
|
354 |
|
355 $this->namespace_stack = array(); |
|
356 $this->context_stack = array(); |
|
357 |
|
358 $result = $this->_process_directives( $html ); |
|
359 |
|
360 $this->namespace_stack = null; |
|
361 $this->context_stack = null; |
|
362 |
|
363 return null === $result ? $html : $result; |
|
364 } |
|
365 |
|
366 /** |
|
367 * Processes the interactivity directives contained within the HTML content |
|
368 * and updates the markup accordingly. |
|
369 * |
|
370 * It uses the WP_Interactivity_API instance's context and namespace stacks, |
|
371 * which are shared between all calls. |
|
372 * |
|
373 * This method returns null if the HTML contains unbalanced tags. |
|
374 * |
|
375 * @since 6.6.0 |
|
376 * |
|
377 * @param string $html The HTML content to process. |
|
378 * @return string|null The processed HTML content. It returns null when the HTML contains unbalanced tags. |
|
379 */ |
|
380 private function _process_directives( string $html ) { |
|
381 $p = new WP_Interactivity_API_Directives_Processor( $html ); |
|
382 $tag_stack = array(); |
|
383 $unbalanced = false; |
|
384 |
|
385 $directive_processor_prefixes = array_keys( self::$directive_processors ); |
|
386 $directive_processor_prefixes_reversed = array_reverse( $directive_processor_prefixes ); |
|
387 |
|
388 /* |
|
389 * Save the current size for each stack to restore them in case |
|
390 * the processing finds unbalanced tags. |
|
391 */ |
|
392 $namespace_stack_size = count( $this->namespace_stack ); |
|
393 $context_stack_size = count( $this->context_stack ); |
|
394 |
|
395 while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) { |
|
396 $tag_name = $p->get_tag(); |
|
397 |
|
398 /* |
|
399 * Directives inside SVG and MATH tags are not processed, |
|
400 * as they are not compatible with the Tag Processor yet. |
|
401 * We still process the rest of the HTML. |
|
402 */ |
|
403 if ( 'SVG' === $tag_name || 'MATH' === $tag_name ) { |
|
404 if ( $p->get_attribute_names_with_prefix( 'data-wp-' ) ) { |
|
405 /* translators: 1: SVG or MATH HTML tag, 2: Namespace of the interactive block. */ |
|
406 $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 ) ); |
|
407 _doing_it_wrong( __METHOD__, $message, '6.6.0' ); |
|
408 } |
|
409 $p->skip_to_tag_closer(); |
|
410 continue; |
|
411 } |
|
412 |
|
413 if ( $p->is_tag_closer() ) { |
|
414 list( $opening_tag_name, $directives_prefixes ) = end( $tag_stack ); |
|
415 |
|
416 if ( 0 === count( $tag_stack ) || $opening_tag_name !== $tag_name ) { |
|
417 |
|
418 /* |
|
419 * If the tag stack is empty or the matching opening tag is not the |
|
420 * same than the closing tag, it means the HTML is unbalanced and it |
|
421 * stops processing it. |
|
422 */ |
|
423 $unbalanced = true; |
|
424 break; |
|
425 } else { |
|
426 // Remove the last tag from the stack. |
|
427 array_pop( $tag_stack ); |
|
428 } |
|
429 } else { |
|
430 if ( 0 !== count( $p->get_attribute_names_with_prefix( 'data-wp-each-child' ) ) ) { |
|
431 /* |
|
432 * If the tag has a `data-wp-each-child` directive, jump to its closer |
|
433 * tag because those tags have already been processed. |
|
434 */ |
|
435 $p->next_balanced_tag_closer_tag(); |
|
436 continue; |
|
437 } else { |
|
438 $directives_prefixes = array(); |
|
439 |
|
440 // Checks if there is a server directive processor registered for each directive. |
|
441 foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) { |
|
442 list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name ); |
|
443 if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) { |
|
444 $directives_prefixes[] = $directive_prefix; |
|
445 } |
|
446 } |
|
447 |
|
448 /* |
|
449 * If this tag will visit its closer tag, it adds it to the tag stack |
|
450 * so it can process its closing tag and check for unbalanced tags. |
|
451 */ |
|
452 if ( $p->has_and_visits_its_closer_tag() ) { |
|
453 $tag_stack[] = array( $tag_name, $directives_prefixes ); |
|
454 } |
|
455 } |
|
456 } |
|
457 /* |
|
458 * If the matching opener tag didn't have any directives, it can skip the |
|
459 * processing. |
|
460 */ |
|
461 if ( 0 === count( $directives_prefixes ) ) { |
|
462 continue; |
|
463 } |
|
464 |
|
465 // Directive processing might be different depending on if it is entering the tag or exiting it. |
|
466 $modes = array( |
|
467 'enter' => ! $p->is_tag_closer(), |
|
468 'exit' => $p->is_tag_closer() || ! $p->has_and_visits_its_closer_tag(), |
|
469 ); |
|
470 |
|
471 foreach ( $modes as $mode => $should_run ) { |
|
472 if ( ! $should_run ) { |
|
473 continue; |
|
474 } |
|
475 |
|
476 /* |
|
477 * Sorts the attributes by the order of the `directives_processor` array |
|
478 * and checks what directives are present in this element. |
|
479 */ |
|
480 $existing_directives_prefixes = array_intersect( |
|
481 'enter' === $mode ? $directive_processor_prefixes : $directive_processor_prefixes_reversed, |
|
482 $directives_prefixes |
|
483 ); |
|
484 foreach ( $existing_directives_prefixes as $directive_prefix ) { |
|
485 $func = is_array( self::$directive_processors[ $directive_prefix ] ) |
|
486 ? self::$directive_processors[ $directive_prefix ] |
|
487 : array( $this, self::$directive_processors[ $directive_prefix ] ); |
|
488 |
|
489 call_user_func_array( $func, array( $p, $mode, &$tag_stack ) ); |
|
490 } |
|
491 } |
|
492 } |
|
493 |
|
494 if ( $unbalanced ) { |
|
495 // Reset the namespace and context stacks to their previous values. |
|
496 array_splice( $this->namespace_stack, $namespace_stack_size ); |
|
497 array_splice( $this->context_stack, $context_stack_size ); |
|
498 } |
|
499 |
|
500 /* |
|
501 * It returns null if the HTML is unbalanced because unbalanced HTML is |
|
502 * not safe to process. In that case, the Interactivity API runtime will |
|
503 * update the HTML on the client side during the hydration. It will also |
|
504 * display a notice to the developer to inform them about the issue. |
|
505 */ |
|
506 if ( $unbalanced || 0 < count( $tag_stack ) ) { |
|
507 $tag_errored = 0 < count( $tag_stack ) ? end( $tag_stack )[0] : $tag_name; |
|
508 /* translators: %1s: Namespace processed, %2s: The tag that caused the error; could be any HTML tag. */ |
|
509 $message = sprintf( __( 'Interactivity directives failed to process in "%1$s" due to a missing "%2$s" end tag.' ), end( $this->namespace_stack ), $tag_errored ); |
|
510 _doing_it_wrong( __METHOD__, $message, '6.6.0' ); |
|
511 return null; |
|
512 } |
|
513 |
|
514 return $p->get_updated_html(); |
|
515 } |
|
516 |
|
517 /** |
|
518 * Evaluates the reference path passed to a directive based on the current |
|
519 * store namespace, state and context. |
|
520 * |
|
521 * @since 6.5.0 |
|
522 * @since 6.6.0 The function now adds a warning when the namespace is null, falsy, or the directive value is empty. |
|
523 * @since 6.6.0 Removed `default_namespace` and `context` arguments. |
|
524 * @since 6.6.0 Add support for derived state. |
|
525 * |
|
526 * @param string|true $directive_value The directive attribute value string or `true` when it's a boolean attribute. |
|
527 * @return mixed|null The result of the evaluation. Null if the reference path doesn't exist or the namespace is falsy. |
|
528 */ |
|
529 private function evaluate( $directive_value ) { |
|
530 $default_namespace = end( $this->namespace_stack ); |
|
531 $context = end( $this->context_stack ); |
|
532 |
|
533 list( $ns, $path ) = $this->extract_directive_value( $directive_value, $default_namespace ); |
|
534 if ( ! $ns || ! $path ) { |
|
535 /* translators: %s: The directive value referenced. */ |
|
536 $message = sprintf( __( 'Namespace or reference path cannot be empty. Directive value referenced: %s' ), $directive_value ); |
|
537 _doing_it_wrong( __METHOD__, $message, '6.6.0' ); |
|
538 return null; |
|
539 } |
|
540 |
|
541 $store = array( |
|
542 'state' => $this->state_data[ $ns ] ?? array(), |
|
543 'context' => $context[ $ns ] ?? array(), |
|
544 ); |
|
545 |
|
546 // Checks if the reference path is preceded by a negation operator (!). |
|
547 $should_negate_value = '!' === $path[0]; |
|
548 $path = $should_negate_value ? substr( $path, 1 ) : $path; |
|
549 |
|
550 // Extracts the value from the store using the reference path. |
|
551 $path_segments = explode( '.', $path ); |
|
552 $current = $store; |
|
553 foreach ( $path_segments as $path_segment ) { |
|
554 if ( ( is_array( $current ) || $current instanceof ArrayAccess ) && isset( $current[ $path_segment ] ) ) { |
|
555 $current = $current[ $path_segment ]; |
|
556 } elseif ( is_object( $current ) && isset( $current->$path_segment ) ) { |
|
557 $current = $current->$path_segment; |
|
558 } else { |
|
559 return null; |
|
560 } |
|
561 |
|
562 if ( $current instanceof Closure ) { |
|
563 /* |
|
564 * This state getter's namespace is added to the stack so that |
|
565 * `state()` or `get_config()` read that namespace when called |
|
566 * without specifying one. |
|
567 */ |
|
568 array_push( $this->namespace_stack, $ns ); |
|
569 try { |
|
570 $current = $current(); |
|
571 } catch ( Throwable $e ) { |
|
572 _doing_it_wrong( |
|
573 __METHOD__, |
|
574 sprintf( |
|
575 /* translators: 1: Path pointing to an Interactivity API state property, 2: Namespace for an Interactivity API store. */ |
|
576 __( 'Uncaught error executing a derived state callback with path "%1$s" and namespace "%2$s".' ), |
|
577 $path, |
|
578 $ns |
|
579 ), |
|
580 '6.6.0' |
|
581 ); |
|
582 return null; |
|
583 } finally { |
|
584 // Remove the property's namespace from the stack. |
|
585 array_pop( $this->namespace_stack ); |
|
586 } |
|
587 } |
|
588 } |
|
589 |
|
590 // Returns the opposite if it contains a negation operator (!). |
|
591 return $should_negate_value ? ! $current : $current; |
|
592 } |
|
593 |
|
594 /** |
|
595 * Extracts the directive attribute name to separate and return the directive |
|
596 * prefix and an optional suffix. |
|
597 * |
|
598 * The suffix is the string after the first double hyphen and the prefix is |
|
599 * everything that comes before the suffix. |
|
600 * |
|
601 * Example: |
|
602 * |
|
603 * extract_prefix_and_suffix( 'data-wp-interactive' ) => array( 'data-wp-interactive', null ) |
|
604 * extract_prefix_and_suffix( 'data-wp-bind--src' ) => array( 'data-wp-bind', 'src' ) |
|
605 * extract_prefix_and_suffix( 'data-wp-foo--and--bar' ) => array( 'data-wp-foo', 'and--bar' ) |
|
606 * |
|
607 * @since 6.5.0 |
|
608 * |
|
609 * @param string $directive_name The directive attribute name. |
|
610 * @return array An array containing the directive prefix and optional suffix. |
|
611 */ |
|
612 private function extract_prefix_and_suffix( string $directive_name ): array { |
|
613 return explode( '--', $directive_name, 2 ); |
|
614 } |
|
615 |
|
616 /** |
|
617 * Parses and extracts the namespace and reference path from the given |
|
618 * directive attribute value. |
|
619 * |
|
620 * If the value doesn't contain an explicit namespace, it returns the |
|
621 * default one. If the value contains a JSON object instead of a reference |
|
622 * path, the function tries to parse it and return the resulting array. If |
|
623 * the value contains strings that represent booleans ("true" and "false"), |
|
624 * numbers ("1" and "1.2") or "null", the function also transform them to |
|
625 * regular booleans, numbers and `null`. |
|
626 * |
|
627 * Example: |
|
628 * |
|
629 * extract_directive_value( 'actions.foo', 'myPlugin' ) => array( 'myPlugin', 'actions.foo' ) |
|
630 * extract_directive_value( 'otherPlugin::actions.foo', 'myPlugin' ) => array( 'otherPlugin', 'actions.foo' ) |
|
631 * extract_directive_value( '{ "isOpen": false }', 'myPlugin' ) => array( 'myPlugin', array( 'isOpen' => false ) ) |
|
632 * extract_directive_value( 'otherPlugin::{ "isOpen": false }', 'myPlugin' ) => array( 'otherPlugin', array( 'isOpen' => false ) ) |
|
633 * |
|
634 * @since 6.5.0 |
|
635 * |
|
636 * @param string|true $directive_value The directive attribute value. It can be `true` when it's a boolean |
|
637 * attribute. |
|
638 * @param string|null $default_namespace Optional. The default namespace if none is explicitly defined. |
|
639 * @return array An array containing the namespace in the first item and the JSON, the reference path, or null on the |
|
640 * second item. |
|
641 */ |
|
642 private function extract_directive_value( $directive_value, $default_namespace = null ): array { |
|
643 if ( empty( $directive_value ) || is_bool( $directive_value ) ) { |
|
644 return array( $default_namespace, null ); |
|
645 } |
|
646 |
|
647 // Replaces the value and namespace if there is a namespace in the value. |
|
648 if ( 1 === preg_match( '/^([\w\-_\/]+)::./', $directive_value ) ) { |
|
649 list($default_namespace, $directive_value) = explode( '::', $directive_value, 2 ); |
|
650 } |
|
651 |
|
652 /* |
|
653 * Tries to decode the value as a JSON object. If it fails and the value |
|
654 * isn't `null`, it returns the value as it is. Otherwise, it returns the |
|
655 * decoded JSON or null for the string `null`. |
|
656 */ |
|
657 $decoded_json = json_decode( $directive_value, true ); |
|
658 if ( null !== $decoded_json || 'null' === $directive_value ) { |
|
659 $directive_value = $decoded_json; |
|
660 } |
|
661 |
|
662 return array( $default_namespace, $directive_value ); |
|
663 } |
|
664 |
|
665 /** |
|
666 * Transforms a kebab-case string to camelCase. |
|
667 * |
|
668 * @param string $str The kebab-case string to transform to camelCase. |
|
669 * @return string The transformed camelCase string. |
|
670 */ |
|
671 private function kebab_to_camel_case( string $str ): string { |
|
672 return lcfirst( |
|
673 preg_replace_callback( |
|
674 '/(-)([a-z])/', |
|
675 function ( $matches ) { |
|
676 return strtoupper( $matches[2] ); |
|
677 }, |
|
678 strtolower( rtrim( $str, '-' ) ) |
|
679 ) |
|
680 ); |
|
681 } |
|
682 |
|
683 /** |
|
684 * Processes the `data-wp-interactive` directive. |
|
685 * |
|
686 * It adds the default store namespace defined in the directive value to the |
|
687 * stack so that it's available for the nested interactivity elements. |
|
688 * |
|
689 * @since 6.5.0 |
|
690 * |
|
691 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. |
|
692 * @param string $mode Whether the processing is entering or exiting the tag. |
|
693 */ |
|
694 private function data_wp_interactive_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) { |
|
695 // When exiting tags, it removes the last namespace from the stack. |
|
696 if ( 'exit' === $mode ) { |
|
697 array_pop( $this->namespace_stack ); |
|
698 return; |
|
699 } |
|
700 |
|
701 // Tries to decode the `data-wp-interactive` attribute value. |
|
702 $attribute_value = $p->get_attribute( 'data-wp-interactive' ); |
|
703 |
|
704 /* |
|
705 * Pushes the newly defined namespace or the current one if the |
|
706 * `data-wp-interactive` definition was invalid or does not contain a |
|
707 * namespace. It does so because the function pops out the current namespace |
|
708 * from the stack whenever it finds a `data-wp-interactive`'s closing tag, |
|
709 * independently of whether the previous `data-wp-interactive` definition |
|
710 * contained a valid namespace. |
|
711 */ |
|
712 $new_namespace = null; |
|
713 if ( is_string( $attribute_value ) && ! empty( $attribute_value ) ) { |
|
714 $decoded_json = json_decode( $attribute_value, true ); |
|
715 if ( is_array( $decoded_json ) ) { |
|
716 $new_namespace = $decoded_json['namespace'] ?? null; |
|
717 } else { |
|
718 $new_namespace = $attribute_value; |
|
719 } |
|
720 } |
|
721 $this->namespace_stack[] = ( $new_namespace && 1 === preg_match( '/^([\w\-_\/]+)/', $new_namespace ) ) |
|
722 ? $new_namespace |
|
723 : end( $this->namespace_stack ); |
|
724 } |
|
725 |
|
726 /** |
|
727 * Processes the `data-wp-context` directive. |
|
728 * |
|
729 * It adds the context defined in the directive value to the stack so that |
|
730 * it's available for the nested interactivity elements. |
|
731 * |
|
732 * @since 6.5.0 |
|
733 * |
|
734 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. |
|
735 * @param string $mode Whether the processing is entering or exiting the tag. |
|
736 */ |
|
737 private function data_wp_context_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) { |
|
738 // When exiting tags, it removes the last context from the stack. |
|
739 if ( 'exit' === $mode ) { |
|
740 array_pop( $this->context_stack ); |
|
741 return; |
|
742 } |
|
743 |
|
744 $attribute_value = $p->get_attribute( 'data-wp-context' ); |
|
745 $namespace_value = end( $this->namespace_stack ); |
|
746 |
|
747 // Separates the namespace from the context JSON object. |
|
748 list( $namespace_value, $decoded_json ) = is_string( $attribute_value ) && ! empty( $attribute_value ) |
|
749 ? $this->extract_directive_value( $attribute_value, $namespace_value ) |
|
750 : array( $namespace_value, null ); |
|
751 |
|
752 /* |
|
753 * If there is a namespace, it adds a new context to the stack merging the |
|
754 * previous context with the new one. |
|
755 */ |
|
756 if ( is_string( $namespace_value ) ) { |
|
757 $this->context_stack[] = array_replace_recursive( |
|
758 end( $this->context_stack ) !== false ? end( $this->context_stack ) : array(), |
|
759 array( $namespace_value => is_array( $decoded_json ) ? $decoded_json : array() ) |
|
760 ); |
|
761 } else { |
|
762 /* |
|
763 * If there is no namespace, it pushes the current context to the stack. |
|
764 * It needs to do so because the function pops out the current context |
|
765 * from the stack whenever it finds a `data-wp-context`'s closing tag. |
|
766 */ |
|
767 $this->context_stack[] = end( $this->context_stack ); |
|
768 } |
|
769 } |
|
770 |
|
771 /** |
|
772 * Processes the `data-wp-bind` directive. |
|
773 * |
|
774 * It updates or removes the bound attributes based on the evaluation of its |
|
775 * associated reference. |
|
776 * |
|
777 * @since 6.5.0 |
|
778 * |
|
779 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. |
|
780 * @param string $mode Whether the processing is entering or exiting the tag. |
|
781 */ |
|
782 private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) { |
|
783 if ( 'enter' === $mode ) { |
|
784 $all_bind_directives = $p->get_attribute_names_with_prefix( 'data-wp-bind--' ); |
|
785 |
|
786 foreach ( $all_bind_directives as $attribute_name ) { |
|
787 list( , $bound_attribute ) = $this->extract_prefix_and_suffix( $attribute_name ); |
|
788 if ( empty( $bound_attribute ) ) { |
|
789 return; |
|
790 } |
|
791 |
|
792 $attribute_value = $p->get_attribute( $attribute_name ); |
|
793 $result = $this->evaluate( $attribute_value ); |
|
794 |
|
795 if ( |
|
796 null !== $result && |
|
797 ( |
|
798 false !== $result || |
|
799 ( strlen( $bound_attribute ) > 5 && '-' === $bound_attribute[4] ) |
|
800 ) |
|
801 ) { |
|
802 /* |
|
803 * If the result of the evaluation is a boolean and the attribute is |
|
804 * `aria-` or `data-, convert it to a string "true" or "false". It |
|
805 * follows the exact same logic as Preact because it needs to |
|
806 * replicate what Preact will later do in the client: |
|
807 * https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 |
|
808 */ |
|
809 if ( |
|
810 is_bool( $result ) && |
|
811 ( strlen( $bound_attribute ) > 5 && '-' === $bound_attribute[4] ) |
|
812 ) { |
|
813 $result = $result ? 'true' : 'false'; |
|
814 } |
|
815 $p->set_attribute( $bound_attribute, $result ); |
|
816 } else { |
|
817 $p->remove_attribute( $bound_attribute ); |
|
818 } |
|
819 } |
|
820 } |
|
821 } |
|
822 |
|
823 /** |
|
824 * Processes the `data-wp-class` directive. |
|
825 * |
|
826 * It adds or removes CSS classes in the current HTML element based on the |
|
827 * evaluation of its associated references. |
|
828 * |
|
829 * @since 6.5.0 |
|
830 * |
|
831 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. |
|
832 * @param string $mode Whether the processing is entering or exiting the tag. |
|
833 */ |
|
834 private function data_wp_class_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) { |
|
835 if ( 'enter' === $mode ) { |
|
836 $all_class_directives = $p->get_attribute_names_with_prefix( 'data-wp-class--' ); |
|
837 |
|
838 foreach ( $all_class_directives as $attribute_name ) { |
|
839 list( , $class_name ) = $this->extract_prefix_and_suffix( $attribute_name ); |
|
840 if ( empty( $class_name ) ) { |
|
841 return; |
|
842 } |
|
843 |
|
844 $attribute_value = $p->get_attribute( $attribute_name ); |
|
845 $result = $this->evaluate( $attribute_value ); |
|
846 |
|
847 if ( $result ) { |
|
848 $p->add_class( $class_name ); |
|
849 } else { |
|
850 $p->remove_class( $class_name ); |
|
851 } |
|
852 } |
|
853 } |
|
854 } |
|
855 |
|
856 /** |
|
857 * Processes the `data-wp-style` directive. |
|
858 * |
|
859 * It updates the style attribute value of the current HTML element based on |
|
860 * the evaluation of its associated references. |
|
861 * |
|
862 * @since 6.5.0 |
|
863 * |
|
864 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. |
|
865 * @param string $mode Whether the processing is entering or exiting the tag. |
|
866 */ |
|
867 private function data_wp_style_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) { |
|
868 if ( 'enter' === $mode ) { |
|
869 $all_style_attributes = $p->get_attribute_names_with_prefix( 'data-wp-style--' ); |
|
870 |
|
871 foreach ( $all_style_attributes as $attribute_name ) { |
|
872 list( , $style_property ) = $this->extract_prefix_and_suffix( $attribute_name ); |
|
873 if ( empty( $style_property ) ) { |
|
874 continue; |
|
875 } |
|
876 |
|
877 $directive_attribute_value = $p->get_attribute( $attribute_name ); |
|
878 $style_property_value = $this->evaluate( $directive_attribute_value ); |
|
879 $style_attribute_value = $p->get_attribute( 'style' ); |
|
880 $style_attribute_value = ( $style_attribute_value && ! is_bool( $style_attribute_value ) ) ? $style_attribute_value : ''; |
|
881 |
|
882 /* |
|
883 * Checks first if the style property is not falsy and the style |
|
884 * attribute value is not empty because if it is, it doesn't need to |
|
885 * update the attribute value. |
|
886 */ |
|
887 if ( $style_property_value || $style_attribute_value ) { |
|
888 $style_attribute_value = $this->merge_style_property( $style_attribute_value, $style_property, $style_property_value ); |
|
889 /* |
|
890 * If the style attribute value is not empty, it sets it. Otherwise, |
|
891 * it removes it. |
|
892 */ |
|
893 if ( ! empty( $style_attribute_value ) ) { |
|
894 $p->set_attribute( 'style', $style_attribute_value ); |
|
895 } else { |
|
896 $p->remove_attribute( 'style' ); |
|
897 } |
|
898 } |
|
899 } |
|
900 } |
|
901 } |
|
902 |
|
903 /** |
|
904 * Merges an individual style property in the `style` attribute of an HTML |
|
905 * element, updating or removing the property when necessary. |
|
906 * |
|
907 * If a property is modified, the old one is removed and the new one is added |
|
908 * at the end of the list. |
|
909 * |
|
910 * @since 6.5.0 |
|
911 * |
|
912 * Example: |
|
913 * |
|
914 * merge_style_property( 'color:green;', 'color', 'red' ) => 'color:red;' |
|
915 * merge_style_property( 'background:green;', 'color', 'red' ) => 'background:green;color:red;' |
|
916 * merge_style_property( 'color:green;', 'color', null ) => '' |
|
917 * |
|
918 * @param string $style_attribute_value The current style attribute value. |
|
919 * @param string $style_property_name The style property name to set. |
|
920 * @param string|false|null $style_property_value The value to set for the style property. With false, null or an |
|
921 * empty string, it removes the style property. |
|
922 * @return string The new style attribute value after the specified property has been added, updated or removed. |
|
923 */ |
|
924 private function merge_style_property( string $style_attribute_value, string $style_property_name, $style_property_value ): string { |
|
925 $style_assignments = explode( ';', $style_attribute_value ); |
|
926 $result = array(); |
|
927 $style_property_value = ! empty( $style_property_value ) ? rtrim( trim( $style_property_value ), ';' ) : null; |
|
928 $new_style_property = $style_property_value ? $style_property_name . ':' . $style_property_value . ';' : ''; |
|
929 |
|
930 // Generates an array with all the properties but the modified one. |
|
931 foreach ( $style_assignments as $style_assignment ) { |
|
932 if ( empty( trim( $style_assignment ) ) ) { |
|
933 continue; |
|
934 } |
|
935 list( $name, $value ) = explode( ':', $style_assignment ); |
|
936 if ( trim( $name ) !== $style_property_name ) { |
|
937 $result[] = trim( $name ) . ':' . trim( $value ) . ';'; |
|
938 } |
|
939 } |
|
940 |
|
941 // Adds the new/modified property at the end of the list. |
|
942 $result[] = $new_style_property; |
|
943 |
|
944 return implode( '', $result ); |
|
945 } |
|
946 |
|
947 /** |
|
948 * Processes the `data-wp-text` directive. |
|
949 * |
|
950 * It updates the inner content of the current HTML element based on the |
|
951 * evaluation of its associated reference. |
|
952 * |
|
953 * @since 6.5.0 |
|
954 * |
|
955 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. |
|
956 * @param string $mode Whether the processing is entering or exiting the tag. |
|
957 */ |
|
958 private function data_wp_text_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) { |
|
959 if ( 'enter' === $mode ) { |
|
960 $attribute_value = $p->get_attribute( 'data-wp-text' ); |
|
961 $result = $this->evaluate( $attribute_value ); |
|
962 |
|
963 /* |
|
964 * Follows the same logic as Preact in the client and only changes the |
|
965 * content if the value is a string or a number. Otherwise, it removes the |
|
966 * content. |
|
967 */ |
|
968 if ( is_string( $result ) || is_numeric( $result ) ) { |
|
969 $p->set_content_between_balanced_tags( esc_html( $result ) ); |
|
970 } else { |
|
971 $p->set_content_between_balanced_tags( '' ); |
|
972 } |
|
973 } |
|
974 } |
|
975 |
|
976 /** |
|
977 * Returns the CSS styles for animating the top loading bar in the router. |
|
978 * |
|
979 * @since 6.5.0 |
|
980 * |
|
981 * @return string The CSS styles for the router's top loading bar animation. |
|
982 */ |
|
983 private function get_router_animation_styles(): string { |
|
984 return <<<CSS |
|
985 .wp-interactivity-router-loading-bar { |
|
986 position: fixed; |
|
987 top: 0; |
|
988 left: 0; |
|
989 margin: 0; |
|
990 padding: 0; |
|
991 width: 100vw; |
|
992 max-width: 100vw !important; |
|
993 height: 4px; |
|
994 background-color: #000; |
|
995 opacity: 0 |
|
996 } |
|
997 .wp-interactivity-router-loading-bar.start-animation { |
|
998 animation: wp-interactivity-router-loading-bar-start-animation 30s cubic-bezier(0.03, 0.5, 0, 1) forwards |
|
999 } |
|
1000 .wp-interactivity-router-loading-bar.finish-animation { |
|
1001 animation: wp-interactivity-router-loading-bar-finish-animation 300ms ease-in |
|
1002 } |
|
1003 @keyframes wp-interactivity-router-loading-bar-start-animation { |
|
1004 0% { transform: scaleX(0); transform-origin: 0 0; opacity: 1 } |
|
1005 100% { transform: scaleX(1); transform-origin: 0 0; opacity: 1 } |
|
1006 } |
|
1007 @keyframes wp-interactivity-router-loading-bar-finish-animation { |
|
1008 0% { opacity: 1 } |
|
1009 50% { opacity: 1 } |
|
1010 100% { opacity: 0 } |
|
1011 } |
|
1012 CSS; |
|
1013 } |
|
1014 |
|
1015 /** |
|
1016 * Outputs the markup for the top loading indicator and the screen reader |
|
1017 * notifications during client-side navigations. |
|
1018 * |
|
1019 * This method prints a div element representing a loading bar visible during |
|
1020 * navigation, as well as an aria-live region that can be read by screen |
|
1021 * readers to announce navigation status. |
|
1022 * |
|
1023 * @since 6.5.0 |
|
1024 */ |
|
1025 public function print_router_loading_and_screen_reader_markup() { |
|
1026 echo <<<HTML |
|
1027 <div |
|
1028 class="wp-interactivity-router-loading-bar" |
|
1029 data-wp-interactive="core/router" |
|
1030 data-wp-class--start-animation="state.navigation.hasStarted" |
|
1031 data-wp-class--finish-animation="state.navigation.hasFinished" |
|
1032 ></div> |
|
1033 <div |
|
1034 class="screen-reader-text" |
|
1035 aria-live="polite" |
|
1036 data-wp-interactive="core/router" |
|
1037 data-wp-text="state.navigation.message" |
|
1038 ></div> |
|
1039 HTML; |
|
1040 } |
|
1041 |
|
1042 /** |
|
1043 * Processes the `data-wp-router-region` directive. |
|
1044 * |
|
1045 * It renders in the footer a set of HTML elements to notify users about |
|
1046 * client-side navigations. More concretely, the elements added are 1) a |
|
1047 * top loading bar to visually inform that a navigation is in progress |
|
1048 * and 2) an `aria-live` region for accessible navigation announcements. |
|
1049 * |
|
1050 * @since 6.5.0 |
|
1051 * |
|
1052 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. |
|
1053 * @param string $mode Whether the processing is entering or exiting the tag. |
|
1054 */ |
|
1055 private function data_wp_router_region_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) { |
|
1056 if ( 'enter' === $mode && ! $this->has_processed_router_region ) { |
|
1057 $this->has_processed_router_region = true; |
|
1058 |
|
1059 // Initialize the `core/router` store. |
|
1060 $this->state( |
|
1061 'core/router', |
|
1062 array( |
|
1063 'navigation' => array( |
|
1064 'texts' => array( |
|
1065 'loading' => __( 'Loading page, please wait.' ), |
|
1066 'loaded' => __( 'Page Loaded.' ), |
|
1067 ), |
|
1068 ), |
|
1069 ) |
|
1070 ); |
|
1071 |
|
1072 // Enqueues as an inline style. |
|
1073 wp_register_style( 'wp-interactivity-router-animations', false ); |
|
1074 wp_add_inline_style( 'wp-interactivity-router-animations', $this->get_router_animation_styles() ); |
|
1075 wp_enqueue_style( 'wp-interactivity-router-animations' ); |
|
1076 |
|
1077 // Adds the necessary markup to the footer. |
|
1078 add_action( 'wp_footer', array( $this, 'print_router_loading_and_screen_reader_markup' ) ); |
|
1079 } |
|
1080 } |
|
1081 |
|
1082 /** |
|
1083 * Processes the `data-wp-each` directive. |
|
1084 * |
|
1085 * This directive gets an array passed as reference and iterates over it |
|
1086 * generating new content for each item based on the inner markup of the |
|
1087 * `template` tag. |
|
1088 * |
|
1089 * @since 6.5.0 |
|
1090 * |
|
1091 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. |
|
1092 * @param string $mode Whether the processing is entering or exiting the tag. |
|
1093 * @param array $tag_stack The reference to the tag stack. |
|
1094 */ |
|
1095 private function data_wp_each_processor( WP_Interactivity_API_Directives_Processor $p, string $mode, array &$tag_stack ) { |
|
1096 if ( 'enter' === $mode && 'TEMPLATE' === $p->get_tag() ) { |
|
1097 $attribute_name = $p->get_attribute_names_with_prefix( 'data-wp-each' )[0]; |
|
1098 $extracted_suffix = $this->extract_prefix_and_suffix( $attribute_name ); |
|
1099 $item_name = isset( $extracted_suffix[1] ) ? $this->kebab_to_camel_case( $extracted_suffix[1] ) : 'item'; |
|
1100 $attribute_value = $p->get_attribute( $attribute_name ); |
|
1101 $result = $this->evaluate( $attribute_value ); |
|
1102 |
|
1103 // Gets the content between the template tags and leaves the cursor in the closer tag. |
|
1104 $inner_content = $p->get_content_between_balanced_template_tags(); |
|
1105 |
|
1106 // Checks if there is a manual server-side directive processing. |
|
1107 $template_end = 'data-wp-each: template end'; |
|
1108 $p->set_bookmark( $template_end ); |
|
1109 $p->next_tag(); |
|
1110 $manual_sdp = $p->get_attribute( 'data-wp-each-child' ); |
|
1111 $p->seek( $template_end ); // Rewinds to the template closer tag. |
|
1112 $p->release_bookmark( $template_end ); |
|
1113 |
|
1114 /* |
|
1115 * It doesn't process in these situations: |
|
1116 * - Manual server-side directive processing. |
|
1117 * - Empty or non-array values. |
|
1118 * - Associative arrays because those are deserialized as objects in JS. |
|
1119 * - Templates that contain top-level texts because those texts can't be |
|
1120 * identified and removed in the client. |
|
1121 */ |
|
1122 if ( |
|
1123 $manual_sdp || |
|
1124 empty( $result ) || |
|
1125 ! is_array( $result ) || |
|
1126 ! array_is_list( $result ) || |
|
1127 ! str_starts_with( trim( $inner_content ), '<' ) || |
|
1128 ! str_ends_with( trim( $inner_content ), '>' ) |
|
1129 ) { |
|
1130 array_pop( $tag_stack ); |
|
1131 return; |
|
1132 } |
|
1133 |
|
1134 // Extracts the namespace from the directive attribute value. |
|
1135 $namespace_value = end( $this->namespace_stack ); |
|
1136 list( $namespace_value ) = is_string( $attribute_value ) && ! empty( $attribute_value ) |
|
1137 ? $this->extract_directive_value( $attribute_value, $namespace_value ) |
|
1138 : array( $namespace_value, null ); |
|
1139 |
|
1140 // Processes the inner content for each item of the array. |
|
1141 $processed_content = ''; |
|
1142 foreach ( $result as $item ) { |
|
1143 // Creates a new context that includes the current item of the array. |
|
1144 $this->context_stack[] = array_replace_recursive( |
|
1145 end( $this->context_stack ) !== false ? end( $this->context_stack ) : array(), |
|
1146 array( $namespace_value => array( $item_name => $item ) ) |
|
1147 ); |
|
1148 |
|
1149 // Processes the inner content with the new context. |
|
1150 $processed_item = $this->_process_directives( $inner_content ); |
|
1151 |
|
1152 if ( null === $processed_item ) { |
|
1153 // If the HTML is unbalanced, stop processing it. |
|
1154 array_pop( $this->context_stack ); |
|
1155 return; |
|
1156 } |
|
1157 |
|
1158 // Adds the `data-wp-each-child` to each top-level tag. |
|
1159 $i = new WP_Interactivity_API_Directives_Processor( $processed_item ); |
|
1160 while ( $i->next_tag() ) { |
|
1161 $i->set_attribute( 'data-wp-each-child', true ); |
|
1162 $i->next_balanced_tag_closer_tag(); |
|
1163 } |
|
1164 $processed_content .= $i->get_updated_html(); |
|
1165 |
|
1166 // Removes the current context from the stack. |
|
1167 array_pop( $this->context_stack ); |
|
1168 } |
|
1169 |
|
1170 // Appends the processed content after the tag closer of the template. |
|
1171 $p->append_content_after_template_tag_closer( $processed_content ); |
|
1172 |
|
1173 // Pops the last tag because it skipped the closing tag of the template tag. |
|
1174 array_pop( $tag_stack ); |
|
1175 } |
|
1176 } |
|
1177 } |