wp/wp-includes/interactivity-api/class-wp-interactivity-api.php
changeset 22 8c2e4d02f4ef
parent 21 48c4eec2b7e6
--- a/wp/wp-includes/interactivity-api/class-wp-interactivity-api.php	Fri Sep 05 18:40:08 2025 +0200
+++ b/wp/wp-includes/interactivity-api/class-wp-interactivity-api.php	Fri Sep 05 18:52:52 2025 +0200
@@ -96,6 +96,16 @@
 	private $context_stack = null;
 
 	/**
+	 * Representation in array format of the element currently being processed.
+	 *
+	 * This is only available during directive processing, otherwise it is `null`.
+	 *
+	 * @since 6.7.0
+	 * @var array{attributes: array<string, string|bool>}|null
+	 */
+	private $current_element = null;
+
+	/**
 	 * Gets and/or sets the initial state of an Interactivity API store for a
 	 * given namespace.
 	 *
@@ -192,13 +202,48 @@
 	 * configuration will be available using a `getConfig` utility.
 	 *
 	 * @since 6.5.0
+	 *
+	 * @deprecated 6.7.0 Client data passing is handled by the {@see "script_module_data_{$module_id}"} filter.
 	 */
 	public function print_client_interactivity_data() {
-		if ( empty( $this->state_data ) && empty( $this->config_data ) ) {
-			return;
+		_deprecated_function( __METHOD__, '6.7.0' );
+	}
+
+	/**
+	 * Set client-side interactivity-router data.
+	 *
+	 * Once in the browser, the state will be parsed and used to hydrate the client-side
+	 * interactivity stores and the configuration will be available using a `getConfig` utility.
+	 *
+	 * @since 6.7.0
+	 *
+	 * @param array $data Data to filter.
+	 * @return array Data for the Interactivity Router script module.
+	 */
+	public function filter_script_module_interactivity_router_data( array $data ): array {
+		if ( ! isset( $data['i18n'] ) ) {
+			$data['i18n'] = array();
 		}
+		$data['i18n']['loading'] = __( 'Loading page, please wait.' );
+		$data['i18n']['loaded']  = __( 'Page Loaded.' );
+		return $data;
+	}
 
-		$interactivity_data = array();
+	/**
+	 * Set client-side interactivity data.
+	 *
+	 * Once in the browser, the state will be parsed and used to hydrate the client-side
+	 * interactivity stores and the configuration will be available using a `getConfig` utility.
+	 *
+	 * @since 6.7.0
+	 *
+	 * @param array $data Data to filter.
+	 * @return array Data for the Interactivity API script module.
+	 */
+	public function filter_script_module_interactivity_data( array $data ): array {
+		if ( empty( $this->state_data ) && empty( $this->config_data ) ) {
+			return $data;
+		}
 
 		$config = array();
 		foreach ( $this->config_data as $key => $value ) {
@@ -207,7 +252,7 @@
 			}
 		}
 		if ( ! empty( $config ) ) {
-			$interactivity_data['config'] = $config;
+			$data['config'] = $config;
 		}
 
 		$state = array();
@@ -217,52 +262,10 @@
 			}
 		}
 		if ( ! empty( $state ) ) {
-			$interactivity_data['state'] = $state;
+			$data['state'] = $state;
 		}
 
-		if ( ! empty( $interactivity_data ) ) {
-			/*
-			 * This data will be printed as JSON inside a script tag like this:
-			 *   <script type="application/json"></script>
-			 *
-			 * A script tag must be closed by a sequence beginning with `</`. It's impossible to
-			 * close a script tag without using `<`. We ensure that `<` is escaped and `/` can
-			 * remain unescaped, so `</script>` will be printed as `\u003C/script\u00E3`.
-			 *
-			 *   - JSON_HEX_TAG: All < and > are converted to \u003C and \u003E.
-			 *   - JSON_UNESCAPED_SLASHES: Don't escape /.
-			 *
-			 * If the page will use UTF-8 encoding, it's safe to print unescaped unicode:
-			 *
-			 *   - JSON_UNESCAPED_UNICODE: Encode multibyte Unicode characters literally (instead of as `\uXXXX`).
-			 *   - JSON_UNESCAPED_LINE_TERMINATORS: The line terminators are kept unescaped when
-			 *     JSON_UNESCAPED_UNICODE is supplied. It uses the same behaviour as it was
-			 *     before PHP 7.1 without this constant. Available as of PHP 7.1.0.
-			 *
-			 * The JSON specification requires encoding in UTF-8, so if the generated HTML page
-			 * is not encoded in UTF-8 then it's not safe to include those literals. They must
-			 * be escaped to avoid encoding issues.
-			 *
-			 * @see https://www.rfc-editor.org/rfc/rfc8259.html for details on encoding requirements.
-			 * @see https://www.php.net/manual/en/json.constants.php for details on these constants.
-			 * @see https://html.spec.whatwg.org/#script-data-state for details on script tag parsing.
-			 */
-			$json_encode_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_LINE_TERMINATORS;
-			if ( ! is_utf8_charset() ) {
-				$json_encode_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES;
-			}
-
-			wp_print_inline_script_tag(
-				wp_json_encode(
-					$interactivity_data,
-					$json_encode_flags
-				),
-				array(
-					'type' => 'application/json',
-					'id'   => 'wp-interactivity-data',
-				)
-			);
-		}
+		return $data;
 	}
 
 	/**
@@ -306,23 +309,35 @@
 	}
 
 	/**
+	 * Returns an array representation of the current element being processed.
+	 *
+	 * The returned array contains a copy of the element attributes.
+	 *
+	 * @since 6.7.0
+	 *
+	 * @return array{attributes: array<string, string|bool>}|null Current element.
+	 */
+	public function get_element(): ?array {
+		if ( null === $this->current_element ) {
+			_doing_it_wrong(
+				__METHOD__,
+				__( 'The element can only be read during directive processing.' ),
+				'6.7.0'
+			);
+		}
+
+		return $this->current_element;
+	}
+
+	/**
 	 * Registers the `@wordpress/interactivity` script modules.
 	 *
+	 * @deprecated 6.7.0 Script Modules registration is handled by {@see wp_default_script_modules()}.
+	 *
 	 * @since 6.5.0
 	 */
 	public function register_script_modules() {
-		$suffix = wp_scripts_get_suffix();
-
-		wp_register_script_module(
-			'@wordpress/interactivity',
-			includes_url( "js/dist/interactivity$suffix.js" )
-		);
-
-		wp_register_script_module(
-			'@wordpress/interactivity-router',
-			includes_url( "js/dist/interactivity-router$suffix.js" ),
-			array( '@wordpress/interactivity' )
-		);
+		_deprecated_function( __METHOD__, '6.7.0', 'wp_default_script_modules' );
 	}
 
 	/**
@@ -331,11 +346,8 @@
 	 * @since 6.5.0
 	 */
 	public function add_hooks() {
-		add_action( 'wp_enqueue_scripts', array( $this, 'register_script_modules' ) );
-		add_action( 'wp_footer', array( $this, 'print_client_interactivity_data' ) );
-
-		add_action( 'admin_enqueue_scripts', array( $this, 'register_script_modules' ) );
-		add_action( 'admin_print_footer_scripts', array( $this, 'print_client_interactivity_data' ) );
+		add_filter( 'script_module_data_@wordpress/interactivity', array( $this, 'filter_script_module_interactivity_data' ) );
+		add_filter( 'script_module_data_@wordpress/interactivity-router', array( $this, 'filter_script_module_interactivity_router_data' ) );
 	}
 
 	/**
@@ -439,6 +451,26 @@
 
 					// Checks if there is a server directive processor registered for each directive.
 					foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) {
+						if ( ! preg_match(
+							/*
+							 * This must align with the client-side regex used by the interactivity API.
+							 * @see https://github.com/WordPress/gutenberg/blob/ca616014255efbb61f34c10917d52a2d86c1c660/packages/interactivity/src/vdom.ts#L20-L32
+							 */
+							'/' .
+							'^data-wp-' .
+							// Match alphanumeric characters including hyphen-separated
+							// segments. It excludes underscore intentionally to prevent confusion.
+							// E.g., "custom-directive".
+							'([a-z0-9]+(?:-[a-z0-9]+)*)' .
+							// (Optional) Match '--' followed by any alphanumeric charachters. It
+							// excludes underscore intentionally to prevent confusion, but it can
+							// contain multiple hyphens. E.g., "--custom-prefix--with-more-info".
+							'(?:--([a-z0-9_-]+))?$' .
+							'/i',
+							$attribute_name
+						) ) {
+							continue;
+						}
 						list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name );
 						if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) {
 							$directives_prefixes[] = $directive_prefix;
@@ -468,6 +500,19 @@
 				'exit'  => $p->is_tag_closer() || ! $p->has_and_visits_its_closer_tag(),
 			);
 
+			// Get the element attributes to include them in the element representation.
+			$element_attrs = array();
+			$attr_names    = $p->get_attribute_names_with_prefix( '' ) ?? array();
+
+			foreach ( $attr_names as $name ) {
+				$element_attrs[ $name ] = $p->get_attribute( $name );
+			}
+
+			// Assign the current element right before running its directive processors.
+			$this->current_element = array(
+				'attributes' => $element_attrs,
+			);
+
 			foreach ( $modes as $mode => $should_run ) {
 				if ( ! $should_run ) {
 					continue;
@@ -489,6 +534,9 @@
 					call_user_func_array( $func, array( $p, $mode, &$tag_stack ) );
 				}
 			}
+
+			// Clear the current element.
+			$this->current_element = null;
 		}
 
 		if ( $unbalanced ) {
@@ -551,12 +599,43 @@
 		$path_segments = explode( '.', $path );
 		$current       = $store;
 		foreach ( $path_segments as $path_segment ) {
+			/*
+			 * Special case for numeric arrays and strings. Add length
+			 * property mimicking JavaScript behavior.
+			 *
+			 * @since 6.8.0
+			 */
+			if ( 'length' === $path_segment ) {
+				if ( is_array( $current ) && array_is_list( $current ) ) {
+					$current = count( $current );
+					break;
+				}
+
+				if ( is_string( $current ) ) {
+					/*
+					 * Differences in encoding between PHP strings and
+					 * JavaScript mean that it's complicated to calculate
+					 * the string length JavaScript would see from PHP.
+					 * `strlen` is a reasonable approximation.
+					 *
+					 * Users that desire a more precise length likely have
+					 * more precise needs than "bytelength" and should
+					 * implement their own length calculation in derived
+					 * state taking into account encoding and their desired
+					 * output (codepoints, graphemes, bytes, etc.).
+					 */
+					$current = strlen( $current );
+					break;
+				}
+			}
+
 			if ( ( is_array( $current ) || $current instanceof ArrayAccess ) && isset( $current[ $path_segment ] ) ) {
 				$current = $current[ $path_segment ];
 			} elseif ( is_object( $current ) && isset( $current->$path_segment ) ) {
 				$current = $current->$path_segment;
 			} else {
-				return null;
+				$current = null;
+				break;
 			}
 
 			if ( $current instanceof Closure ) {
@@ -1013,16 +1092,27 @@
 	}
 
 	/**
-	 * Outputs the markup for the top loading indicator and the screen reader
-	 * notifications during client-side navigations.
+	 * Deprecated.
+	 *
+	 * @since 6.5.0
+	 * @deprecated 6.7.0 Use {@see WP_Interactivity_API::print_router_markup} instead.
+	 */
+	public function print_router_loading_and_screen_reader_markup() {
+		_deprecated_function( __METHOD__, '6.7.0', 'WP_Interactivity_API::print_router_markup' );
+
+		// Call the new method.
+		$this->print_router_markup();
+	}
+
+	/**
+	 * Outputs markup for the @wordpress/interactivity-router script module.
 	 *
 	 * This method prints a div element representing a loading bar visible during
-	 * navigation, as well as an aria-live region that can be read by screen
-	 * readers to announce navigation status.
+	 * navigation.
 	 *
-	 * @since 6.5.0
+	 * @since 6.7.0
 	 */
-	public function print_router_loading_and_screen_reader_markup() {
+	public function print_router_markup() {
 		echo <<<HTML
 			<div
 				class="wp-interactivity-router-loading-bar"
@@ -1030,12 +1120,6 @@
 				data-wp-class--start-animation="state.navigation.hasStarted"
 				data-wp-class--finish-animation="state.navigation.hasFinished"
 			></div>
-			<div
-				class="screen-reader-text"
-				aria-live="polite"
-				data-wp-interactive="core/router"
-				data-wp-text="state.navigation.message"
-			></div>
 HTML;
 	}
 
@@ -1056,26 +1140,13 @@
 		if ( 'enter' === $mode && ! $this->has_processed_router_region ) {
 			$this->has_processed_router_region = true;
 
-			// Initialize the `core/router` store.
-			$this->state(
-				'core/router',
-				array(
-					'navigation' => array(
-						'texts' => array(
-							'loading' => __( 'Loading page, please wait.' ),
-							'loaded'  => __( 'Page Loaded.' ),
-						),
-					),
-				)
-			);
-
 			// Enqueues as an inline style.
 			wp_register_style( 'wp-interactivity-router-animations', false );
 			wp_add_inline_style( 'wp-interactivity-router-animations', $this->get_router_animation_styles() );
 			wp_enqueue_style( 'wp-interactivity-router-animations' );
 
 			// Adds the necessary markup to the footer.
-			add_action( 'wp_footer', array( $this, 'print_router_loading_and_screen_reader_markup' ) );
+			add_action( 'wp_footer', array( $this, 'print_router_markup' ) );
 		}
 	}