diff --git a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php
index 171d0bcffe29f..dc584c014849f 100644
--- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php
+++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php
@@ -478,27 +478,11 @@ private function _process_directives( string $html ) {
// 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
- ) ) {
+ $parsed_directive = $this->parse_directive_name( $attribute_name );
+ if ( empty( $parsed_directive ) ) {
continue;
}
- list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name );
+ $directive_prefix = 'data-wp-' . $parsed_directive['prefix'];
if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) {
$directives_prefixes[] = $directive_prefix;
}
@@ -575,14 +559,10 @@ private function _process_directives( string $html ) {
/*
* It returns null if the HTML is unbalanced because unbalanced HTML is
* not safe to process. In that case, the Interactivity API runtime will
- * update the HTML on the client side during the hydration. It will also
- * display a notice to the developer to inform them about the issue.
+ * update the HTML on the client side during the hydration. It will display
+ * a notice to the developer in the console to inform them about the issue.
*/
if ( $unbalanced || 0 < count( $tag_stack ) ) {
- $tag_errored = 0 < count( $tag_stack ) ? end( $tag_stack )[0] : $tag_name;
- /* translators: %1s: Namespace processed, %2s: The tag that caused the error; could be any HTML tag. */
- $message = sprintf( __( 'Interactivity directives failed to process in "%1$s" due to a missing "%2$s" end tag.' ), end( $this->namespace_stack ), $tag_errored );
- _doing_it_wrong( __METHOD__, $message, '6.6.0' );
return null;
}
@@ -597,18 +577,18 @@ private function _process_directives( string $html ) {
* @since 6.6.0 The function now adds a warning when the namespace is null, falsy, or the directive value is empty.
* @since 6.6.0 Removed `default_namespace` and `context` arguments.
* @since 6.6.0 Add support for derived state.
+ * @since 6.9.0 Recieve $entry as an argument instead of the directive value string.
*
- * @param string|true $directive_value The directive attribute value string or `true` when it's a boolean attribute.
+ * @param array $entry An array containing a whole directive entry with its namespace, value, suffix, or unique ID.
* @return mixed|null The result of the evaluation. Null if the reference path doesn't exist or the namespace is falsy.
*/
- private function evaluate( $directive_value ) {
- $default_namespace = end( $this->namespace_stack );
- $context = end( $this->context_stack );
+ private function evaluate( $entry ) {
+ $context = end( $this->context_stack );
+ ['namespace' => $ns, 'value' => $path] = $entry;
- list( $ns, $path ) = $this->extract_directive_value( $directive_value, $default_namespace );
if ( ! $ns || ! $path ) {
/* translators: %s: The directive value referenced. */
- $message = sprintf( __( 'Namespace or reference path cannot be empty. Directive value referenced: %s' ), $directive_value );
+ $message = sprintf( __( 'Namespace or reference path cannot be empty. Directive value referenced: %s' ), json_encode( $entry ) );
_doing_it_wrong( __METHOD__, $message, '6.6.0' );
return null;
}
@@ -712,25 +692,74 @@ private function evaluate( $directive_value ) {
}
/**
- * Extracts the directive attribute name to separate and return the directive
- * prefix and an optional suffix.
- *
- * The suffix is the string after the first double hyphen and the prefix is
- * everything that comes before the suffix.
+ * Parse the directive name to extract the following parts:
+ * - Prefix: The main directive name without "data-wp-".
+ * - Suffix: An optional suffix used during directive processing, extracted after the first double hyphen "--".
+ * - Unique ID: An optional unique identifier, extracted after the first triple hyphen "---".
*
- * Example:
+ * This function has an equivalent version for the client side.
+ * See `parseDirectiveName` in https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/src/vdom.ts.:
*
- * extract_prefix_and_suffix( 'data-wp-interactive' ) => array( 'data-wp-interactive', null )
- * extract_prefix_and_suffix( 'data-wp-bind--src' ) => array( 'data-wp-bind', 'src' )
- * extract_prefix_and_suffix( 'data-wp-foo--and--bar' ) => array( 'data-wp-foo', 'and--bar' )
+ * See examples in the function unit tests `test_parse_directive_name`.
*
- * @since 6.5.0
+ * @since 6.9.0
*
* @param string $directive_name The directive attribute name.
- * @return array An array containing the directive prefix and optional suffix.
+ * @return array An array containing the directive prefix, optional suffix, and optional unique ID.
*/
- private function extract_prefix_and_suffix( string $directive_name ): array {
- return explode( '--', $directive_name, 2 );
+ private function parse_directive_name( string $directive_name ): ?array {
+ // Remove the first 8 characters (assumes "data-wp-" prefix)
+ $name = substr( $directive_name, 8 );
+
+ // Check for invalid characters (anything not a-z, 0-9, -, or _)
+ if ( preg_match( '/[^a-z0-9\-_]/i', $name ) ) {
+ return null;
+ }
+
+ // Find the first occurrence of '--' to separate the prefix
+ $suffix_index = strpos( $name, '--' );
+
+ if ( false === $suffix_index ) {
+ return array(
+ 'prefix' => $name,
+ 'suffix' => null,
+ 'unique_id' => null,
+ );
+ }
+
+ $prefix = substr( $name, 0, $suffix_index );
+ $remaining = substr( $name, $suffix_index );
+
+ // If remaining starts with '---' but not '----', it's a unique_id
+ if ( '---' === substr( $remaining, 0, 3 ) && '-' !== ( $remaining[3] ?? '' ) ) {
+ return array(
+ 'prefix' => $prefix,
+ 'suffix' => null,
+ 'unique_id' => '---' !== $remaining ? substr( $remaining, 3 ) : null,
+ );
+ }
+
+ // Otherwise, remove the first two dashes for a potential suffix
+ $suffix = substr( $remaining, 2 );
+
+ // Look for '---' in the suffix for a unique_id
+ $unique_id_index = strpos( $suffix, '---' );
+
+ if ( false !== $unique_id_index && '-' !== ( $suffix[ $unique_id_index + 3 ] ?? '' ) ) {
+ $unique_id = substr( $suffix, $unique_id_index + 3 );
+ $suffix = substr( $suffix, 0, $unique_id_index );
+ return array(
+ 'prefix' => $prefix,
+ 'suffix' => empty( $suffix ) ? null : $suffix,
+ 'unique_id' => empty( $unique_id ) ? null : $unique_id,
+ );
+ }
+
+ return array(
+ 'prefix' => $prefix,
+ 'suffix' => empty( $suffix ) ? null : $suffix,
+ 'unique_id' => null,
+ );
}
/**
@@ -782,6 +811,53 @@ private function extract_directive_value( $directive_value, $default_namespace =
return array( $default_namespace, $directive_value );
}
+ /**
+ * Parse the HTML element and get all the valid directives with the given prefix.
+ *
+ * @since 6.9.0
+ *
+ * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
+ * @param string $prefix The directive prefix to filter by.
+ * @return array An array of entries containing the directive namespace, value, suffix, and unique ID.
+ */
+ private function get_directive_entries( WP_Interactivity_API_Directives_Processor $p, string $prefix ) {
+ $directive_attributes = $p->get_attribute_names_with_prefix( 'data-wp-' . $prefix );
+ $entries = array();
+ foreach ( $directive_attributes as $attribute_name ) {
+ [ 'prefix' => $attr_prefix, 'suffix' => $suffix, 'unique_id' => $unique_id] = $this->parse_directive_name( $attribute_name );
+ // Ensure it is the desired directive.
+ if ( $prefix !== $attr_prefix ) {
+ continue;
+ }
+ list( $namespace, $value ) = $this->extract_directive_value( $p->get_attribute( $attribute_name ), end( $this->namespace_stack ) );
+ $entries[] = array(
+ 'namespace' => $namespace,
+ 'value' => $value,
+ 'suffix' => $suffix,
+ 'unique_id' => $unique_id,
+ );
+ }
+ // Sort directive entries to ensure stable ordering with the client.
+ // Put nulls first, then sort by suffix and finally by uniqueIds.
+ usort(
+ $entries,
+ function ( $a, $b ) {
+ $a_suffix = $a['suffix'] ?? '';
+ $b_suffix = $b['suffix'] ?? '';
+ if ( $a_suffix !== $b_suffix ) {
+ return $a_suffix < $b_suffix ? -1 : 1;
+ }
+ $a_id = $a['unique_id'] ?? '';
+ $b_id = $b['unique_id'] ?? '';
+ if ( $a_id === $b_id ) {
+ return 0;
+ }
+ return $a_id > $b_id ? 1 : -1;
+ }
+ );
+ return $entries;
+ }
+
/**
* Transforms a kebab-case string to camelCase.
*
@@ -863,31 +939,19 @@ private function data_wp_context_processor( WP_Interactivity_API_Directives_Proc
return;
}
- $attribute_value = $p->get_attribute( 'data-wp-context' );
- $namespace_value = end( $this->namespace_stack );
-
- // Separates the namespace from the context JSON object.
- list( $namespace_value, $decoded_json ) = is_string( $attribute_value ) && ! empty( $attribute_value )
- ? $this->extract_directive_value( $attribute_value, $namespace_value )
- : array( $namespace_value, null );
+ $entries = $this->get_directive_entries( $p, 'context' );
+ $context = end( $this->context_stack ) !== false ? end( $this->context_stack ) : array();
+ foreach ( $entries as $entry ) {
+ if ( null !== $entry['suffix'] ) {
+ continue;
+ }
- /*
- * If there is a namespace, it adds a new context to the stack merging the
- * previous context with the new one.
- */
- if ( is_string( $namespace_value ) ) {
- $this->context_stack[] = array_replace_recursive(
- end( $this->context_stack ) !== false ? end( $this->context_stack ) : array(),
- array( $namespace_value => is_array( $decoded_json ) ? $decoded_json : array() )
+ $context = array_replace_recursive(
+ $context,
+ array( $entry['namespace'] => is_array( $entry['value'] ) ? $entry['value'] : array() )
);
- } else {
- /*
- * If there is no namespace, it pushes the current context to the stack.
- * It needs to do so because the function pops out the current context
- * from the stack whenever it finds a `data-wp-context`'s closing tag.
- */
- $this->context_stack[] = end( $this->context_stack );
}
+ $this->context_stack[] = $context;
}
/**
@@ -903,22 +967,19 @@ private function data_wp_context_processor( WP_Interactivity_API_Directives_Proc
*/
private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
if ( 'enter' === $mode ) {
- $all_bind_directives = $p->get_attribute_names_with_prefix( 'data-wp-bind--' );
-
- foreach ( $all_bind_directives as $attribute_name ) {
- list( , $bound_attribute ) = $this->extract_prefix_and_suffix( $attribute_name );
- if ( empty( $bound_attribute ) ) {
- return;
+ $entries = $this->get_directive_entries( $p, 'bind' );
+ foreach ( $entries as $entry ) {
+ if ( empty( $entry['suffix'] ) || null !== $entry['unique_id'] ) {
+ return;
}
- $attribute_value = $p->get_attribute( $attribute_name );
- $result = $this->evaluate( $attribute_value );
+ $result = $this->evaluate( $entry );
if (
null !== $result &&
(
false !== $result ||
- ( strlen( $bound_attribute ) > 5 && '-' === $bound_attribute[4] )
+ ( strlen( $entry['suffix'] ) > 5 && '-' === $entry['suffix'][4] )
)
) {
/*
@@ -930,13 +991,13 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process
*/
if (
is_bool( $result ) &&
- ( strlen( $bound_attribute ) > 5 && '-' === $bound_attribute[4] )
+ ( strlen( $entry['suffix'] ) > 5 && '-' === $entry['suffix'][4] )
) {
$result = $result ? 'true' : 'false';
}
- $p->set_attribute( $bound_attribute, $result );
+ $p->set_attribute( $entry['suffix'], $result );
} else {
- $p->remove_attribute( $bound_attribute );
+ $p->remove_attribute( $entry['suffix'] );
}
}
}
@@ -956,15 +1017,20 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process
private function data_wp_class_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
if ( 'enter' === $mode ) {
$all_class_directives = $p->get_attribute_names_with_prefix( 'data-wp-class--' );
+ $entries = $this->get_directive_entries( $p, 'class' );
+ foreach ( $entries as $entry ) {
+ if ( empty( $entry['suffix'] ) ) {
+ continue;
+ }
+ $class_name = isset( $entry['unique_id'] ) && $entry['unique_id']
+ ? "{$entry['suffix']}---{$entry['unique_id']}"
+ : $entry['suffix'];
- foreach ( $all_class_directives as $attribute_name ) {
- list( , $class_name ) = $this->extract_prefix_and_suffix( $attribute_name );
if ( empty( $class_name ) ) {
return;
}
- $attribute_value = $p->get_attribute( $attribute_name );
- $result = $this->evaluate( $attribute_value );
+ $result = $this->evaluate( $entry );
if ( $result ) {
$p->add_class( $class_name );
@@ -988,18 +1054,16 @@ private function data_wp_class_processor( WP_Interactivity_API_Directives_Proces
*/
private function data_wp_style_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
if ( 'enter' === $mode ) {
- $all_style_attributes = $p->get_attribute_names_with_prefix( 'data-wp-style--' );
-
- foreach ( $all_style_attributes as $attribute_name ) {
- list( , $style_property ) = $this->extract_prefix_and_suffix( $attribute_name );
- if ( empty( $style_property ) ) {
+ $entries = $this->get_directive_entries( $p, 'style' );
+ foreach ( $entries as $entry ) {
+ $style_property = $entry['suffix'];
+ if ( empty( $style_property ) || null !== $entry['unique_id'] ) {
continue;
}
- $directive_attribute_value = $p->get_attribute( $attribute_name );
- $style_property_value = $this->evaluate( $directive_attribute_value );
- $style_attribute_value = $p->get_attribute( 'style' );
- $style_attribute_value = ( $style_attribute_value && ! is_bool( $style_attribute_value ) ) ? $style_attribute_value : '';
+ $style_property_value = $this->evaluate( $entry );
+ $style_attribute_value = $p->get_attribute( 'style' );
+ $style_attribute_value = ( $style_attribute_value && ! is_bool( $style_attribute_value ) ) ? $style_attribute_value : '';
/*
* Checks first if the style property is not falsy and the style
@@ -1079,8 +1143,19 @@ private function merge_style_property( string $style_attribute_value, string $st
*/
private function data_wp_text_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
if ( 'enter' === $mode ) {
- $attribute_value = $p->get_attribute( 'data-wp-text' );
- $result = $this->evaluate( $attribute_value );
+ $entries = $this->get_directive_entries( $p, 'text' );
+ $valid_entry = null;
+ // Get the first valid `data-wp-text` entry without suffix or unique ID.
+ foreach ( $entries as $entry ) {
+ if ( null === $entry['suffix'] && null === $entry['unique_id'] && ! empty( $entry['value'] ) ) {
+ $valid_entry = $entry;
+ break;
+ }
+ }
+ if ( null === $valid_entry ) {
+ return;
+ }
+ $result = $this->evaluate( $valid_entry );
/*
* Follows the same logic as Preact in the client and only changes the
@@ -1209,11 +1284,17 @@ private function data_wp_router_region_processor( WP_Interactivity_API_Directive
*/
private function data_wp_each_processor( WP_Interactivity_API_Directives_Processor $p, string $mode, array &$tag_stack ) {
if ( 'enter' === $mode && 'TEMPLATE' === $p->get_tag() ) {
- $attribute_name = $p->get_attribute_names_with_prefix( 'data-wp-each' )[0];
- $extracted_suffix = $this->extract_prefix_and_suffix( $attribute_name );
- $item_name = isset( $extracted_suffix[1] ) ? $this->kebab_to_camel_case( $extracted_suffix[1] ) : 'item';
- $attribute_value = $p->get_attribute( $attribute_name );
- $result = $this->evaluate( $attribute_value );
+ $entries = $this->get_directive_entries( $p, 'each' );
+ if ( count( $entries ) > 1 || empty( $entries ) ) {
+ // There should be only one `data-wp-each` directive per template tag.
+ return;
+ }
+ $entry = $entries[0];
+ if ( null !== $entry['unique_id'] ) {
+ return;
+ }
+ $item_name = isset( $entry['suffix'] ) ? $this->kebab_to_camel_case( $entry['suffix'] ) : 'item';
+ $result = $this->evaluate( $entry );
// Gets the content between the template tags and leaves the cursor in the closer tag.
$inner_content = $p->get_content_between_balanced_template_tags();
@@ -1246,19 +1327,13 @@ private function data_wp_each_processor( WP_Interactivity_API_Directives_Process
return;
}
- // Extracts the namespace from the directive attribute value.
- $namespace_value = end( $this->namespace_stack );
- list( $namespace_value, $path ) = is_string( $attribute_value ) && ! empty( $attribute_value )
- ? $this->extract_directive_value( $attribute_value, $namespace_value )
- : array( $namespace_value, null );
-
// Processes the inner content for each item of the array.
$processed_content = '';
foreach ( $result as $item ) {
// Creates a new context that includes the current item of the array.
$this->context_stack[] = array_replace_recursive(
end( $this->context_stack ) !== false ? end( $this->context_stack ) : array(),
- array( $namespace_value => array( $item_name => $item ) )
+ array( $entry['namespace'] => array( $item_name => $item ) )
);
// Processes the inner content with the new context.
@@ -1283,7 +1358,7 @@ private function data_wp_each_processor( WP_Interactivity_API_Directives_Process
*/
$i = new WP_Interactivity_API_Directives_Processor( $processed_item );
while ( $i->next_tag() ) {
- $i->set_attribute( 'data-wp-each-child', $namespace_value . '::' . $path );
+ $i->set_attribute( 'data-wp-each-child', $entry['namespace'] . '::' . $entry['value'] );
$i->next_balanced_tag_closer_tag();
}
$processed_content .= $i->get_updated_html();
diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php
index 0a6ffcb2a5da0..cd3f17c8ae254 100644
--- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php
+++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php
@@ -398,4 +398,22 @@ public function test_wp_bind_handles_true_value() {
list($p) = $this->process_directives( $html );
$this->assertSame( true, $p->get_attribute( 'id' ) );
}
+
+ /**
+ * Tests ignores unique IDs in bind directive.
+ *
+ * @ticket 64106
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_bind_ignores_unique_ids() {
+ $html = '
Text
';
@@ -154,4 +152,35 @@ public function test_wp_text_cant_set_inner_html_in_the_content() {
$new_html = $this->interactivity->process_directives( $html );
$this->assertSame( '
<span>Updated</span>
', $new_html );
}
+
+ /**
+ * Tests it ignores suffixes and unique-ids.
+ *
+ * @ticket 64106
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_text_ignores_suffixes_and_unique_ids() {
+ $html = '
Text';
+ $new_html = $this->interactivity->process_directives( $html );
+ $this->assertSame( $html, $new_html );
+
+ $html = '
Text';
+ $new_html = $this->interactivity->process_directives( $html );
+ $this->assertSame( $html, $new_html );
+ }
+
+ /**
+ * Tests first `data-wp-text` works even when suffixes and unique-ids are included.
+ *
+ * @ticket 64106
+ *
+ * @covers ::process_directives
+ */
+ public function test_wp_text_works_even_when_suffixes_and_unique_ids_are_included() {
+ $original = '
Text';
+ $expected = '
Updated';
+ $new_html = $this->interactivity->process_directives( $original );
+ $this->assertSame( $expected, $new_html );
+ }
}
diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php
index a64f0f0c956bd..8aa70e188ff15 100644
--- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php
+++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php
@@ -369,14 +369,39 @@ function () use ( $returns_whatever, $returns_array ) {
// Multiple evaluations should be serialized only once.
$this->set_internal_namespace_stack( 'pluginWithInvokedDerivedState' );
- $this->evaluate( 'state.derivedProp' );
- $this->evaluate( 'state.derivedProp' );
- $this->evaluate( 'state.nested.derivedProp' );
- $this->evaluate( 'state.nested.derivedProp' );
+ $this->evaluate(
+ array(
+ 'namespace' => 'pluginWithInvokedDerivedState',
+ 'value' => 'state.derivedProp',
+ )
+ );
+ $this->evaluate(
+ array(
+ 'namespace' => 'pluginWithInvokedDerivedState',
+ 'value' => 'state.derivedProp',
+ )
+ );
+ $this->evaluate(
+ array(
+ 'namespace' => 'pluginWithInvokedDerivedState',
+ 'value' => 'state.nested.derivedProp',
+ )
+ );
+ $this->evaluate(
+ array(
+ 'namespace' => 'pluginWithInvokedDerivedState',
+ 'value' => 'state.nested.derivedProp',
+ )
+ );
// Only the path part that points to a derived state prop should be serialized.
$this->set_internal_namespace_stack( 'pluginWithInvokedDerivedStateReturningArray' );
- $this->evaluate( 'state.nested.derivedProp.prop' );
+ $this->evaluate(
+ array(
+ 'namespace' => 'pluginWithInvokedDerivedStateReturningArray',
+ 'value' => 'state.nested.derivedProp',
+ )
+ );
}
);
@@ -810,24 +835,301 @@ public function test_extract_directive_value_invalid_json() {
* Tests the ability to extract prefix and suffix from a directive attribute
* name.
*
- * @ticket 60356
+ * @ticket 64106
+ *
+ * @covers ::parse_directive_name
+ */
+ public function test_parse_directive_name() {
+ $parse_directive_name = new ReflectionMethod( $this->interactivity, 'parse_directive_name' );
+ if ( PHP_VERSION_ID < 80100 ) {
+ $parse_directive_name->setAccessible( true );
+ }
+
+ // Should parse directives without suffix or unique ID.
+ $result = $parse_directive_name->invoke( $this->interactivity, 'data-wp-test' );
+ $this->assertSame( 'test', $result['prefix'] );
+ $this->assertNull( $result['suffix'] );
+ $this->assertNull( $result['unique_id'] );
+
+ // Should parse directives with suffix only.
+ $result = $parse_directive_name->invoke( $this->interactivity, 'data-wp-test--one' );
+ $this->assertSame( 'test', $result['prefix'] );
+ $this->assertSame( 'one', $result['suffix'] );
+ $this->assertNull( $result['unique_id'] );
+
+ // Should parse directives with unique ID only.
+ $result = $parse_directive_name->invoke( $this->interactivity, 'data-wp-test---unique-id' );
+ $this->assertSame( 'test', $result['prefix'] );
+ $this->assertNull( $result['suffix'] );
+ $this->assertSame( 'unique-id', $result['unique_id'] );
+
+ // Should parse directives with suffix and unique ID.
+ $result = $parse_directive_name->invoke( $this->interactivity, 'data-wp-test--suffix---unique-id' );
+ $this->assertSame( 'test', $result['prefix'] );
+ $this->assertSame( 'suffix', $result['suffix'] );
+ $this->assertSame( 'unique-id', $result['unique_id'] );
+
+ // Should handle empty suffix (just two dashes).
+ $result = $parse_directive_name->invoke( $this->interactivity, 'data-wp-test--' );
+ $this->assertSame( 'test', $result['prefix'] );
+ $this->assertNull( $result['suffix'] );
+ $this->assertNull( $result['unique_id'] );
+
+ // Should handle empty unique ID (just three dashes).
+ $result = $parse_directive_name->invoke( $this->interactivity, 'data-wp-test---' );
+ $this->assertSame( 'test', $result['prefix'] );
+ $this->assertNull( $result['suffix'] );
+ $this->assertNull( $result['unique_id'] );
+
+ // Should handle only dashes (4 or more dashes).
+ $result = $parse_directive_name->invoke( $this->interactivity, 'data-wp-test----' );
+ $this->assertSame( 'test', $result['prefix'] );
+ $this->assertSame( '--', $result['suffix'] );
+ $this->assertNull( $result['unique_id'] );
+
+ // Should handle suffix starting with 4 or more dashes but containing valid characters.
+ $result = $parse_directive_name->invoke( $this->interactivity, 'data-wp-test------custom-suffix' );
+ $this->assertSame( 'test', $result['prefix'] );
+ $this->assertSame( '----custom-suffix', $result['suffix'] );
+ $this->assertNull( $result['unique_id'] );
+
+ // Should handle complex pattern with multiple dashes.
+ $result = $parse_directive_name->invoke( $this->interactivity, 'data-wp-test--complex--suffix---complex--unique---id' );
+ $this->assertSame( 'test', $result['prefix'] );
+ $this->assertSame( 'complex--suffix', $result['suffix'] );
+ $this->assertSame( 'complex--unique---id', $result['unique_id'] );
+
+ // Should handle suffix with dashes followed by unique ID.
+ $result = $parse_directive_name->invoke( $this->interactivity, 'data-wp-test----suffix---unique-id' );
+ $this->assertSame( 'test', $result['prefix'] );
+ $this->assertSame( '--suffix', $result['suffix'] );
+ $this->assertSame( 'unique-id', $result['unique_id'] );
+
+ // Should handle unique IDs followed by suffix in wrong order.
+ $result = $parse_directive_name->invoke( $this->interactivity, 'data-wp-test---unique-id--wrong-suffix' );
+ $this->assertSame( 'test', $result['prefix'] );
+ $this->assertNull( $result['suffix'] );
+ $this->assertSame( 'unique-id--wrong-suffix', $result['unique_id'] );
+ }
+
+ /**
+ * Tests the ability to get the valid entries of a specific directive in an HTML element.
*
- * @covers ::extract_prefix_and_suffix
+ * @ticket 64106
+ *
+ * @covers ::get_directive_entries
*/
- public function test_extract_prefix_and_suffix() {
- $extract_prefix_and_suffix = new ReflectionMethod( $this->interactivity, 'extract_prefix_and_suffix' );
+ public function test_get_directive_entries() {
+ $get_directive_entries = new ReflectionMethod( $this->interactivity, 'get_directive_entries' );
if ( PHP_VERSION_ID < 80100 ) {
- $extract_prefix_and_suffix->setAccessible( true );
+ $get_directive_entries->setAccessible( true );
}
+ $this->set_internal_namespace_stack( 'myPlugin' );
+
+ // Should process simple directives.
+ $html = '
';
+ $p = new WP_Interactivity_API_Directives_Processor( $html );
+ $p->next_tag();
+ $results = $get_directive_entries->invoke( $this->interactivity, $p, 'test' );
+ $this->assertCount( 1, $results );
+ $result = $results[0];
+ $this->assertSame( 'myPlugin', $result['namespace'] );
+ $this->assertSame( 'test value', $result['value'] );
+ $this->assertNull( $result['suffix'] );
+ $this->assertNull( $result['unique_id'] );
+
+ // Should process directives without value.
+ $html = '
';
+ $p = new WP_Interactivity_API_Directives_Processor( $html );
+ $p->next_tag();
+ $results = $get_directive_entries->invoke( $this->interactivity, $p, 'test' );
+ $this->assertNull( $results[0]['value'] );
+
+ // Should parse JSON values in directives.
+ $html = '
';
+ $p = new WP_Interactivity_API_Directives_Processor( $html );
+ $p->next_tag();
+ $results = $get_directive_entries->invoke( $this->interactivity, $p, 'test' );
+ $this->assertSame( array( 'key' => 'value' ), $results[0]['value'] );
+
+ // Should handle malformed JSON and keep as string.
+ $html = '
';
+ $p = new WP_Interactivity_API_Directives_Processor( $html );
+ $p->next_tag();
+ $results = $get_directive_entries->invoke( $this->interactivity, $p, 'test' );
+ $this->assertSame( '{malformed: json}', $results[0]['value'] );
+
+ // Should process directives with a custom namespace.
+ $html = '
';
+ $p = new WP_Interactivity_API_Directives_Processor( $html );
+ $p->next_tag();
+ $results = $get_directive_entries->invoke( $this->interactivity, $p, 'test' );
+ $this->assertSame( 'my-namespace', $results[0]['namespace'] );
+ $this->assertSame( 'test value', $results[0]['value'] );
+
+ // Should parse JSON values with a custom namespace.
+ $html = '
';
+ $p = new WP_Interactivity_API_Directives_Processor( $html );
+ $p->next_tag();
+ $results = $get_directive_entries->invoke( $this->interactivity, $p, 'test' );
+ $this->assertSame( 'my-namespace', $results[0]['namespace'] );
+ $this->assertSame( array( 'key' => 'value' ), $results[0]['value'] );
+
+ // Should handle multiple directives with different unique IDs.
+ $html = '
+
';
+ $p = new WP_Interactivity_API_Directives_Processor( $html );
+ $p->next_tag();
+ $results = $get_directive_entries->invoke( $this->interactivity, $p, 'test' );
+ $this->assertCount( 3, $results );
+ $this->assertSame(
+ array(
+ 'namespace' => 'myPlugin',
+ 'value' => 'value-a',
+ 'suffix' => null,
+ 'unique_id' => 'plugin-a',
+ ),
+ $results[0]
+ );
+ $this->assertSame(
+ array(
+ 'namespace' => 'myPlugin',
+ 'value' => 'value-b',
+ 'suffix' => null,
+ 'unique_id' => 'plugin-b',
+ ),
+ $results[1]
+ );
+ $this->assertSame(
+ array(
+ 'namespace' => 'myPlugin',
+ 'value' => 'value-c',
+ 'suffix' => null,
+ 'unique_id' => 'plugin-c',
+ ),
+ $results[2]
+ );
- $result = $extract_prefix_and_suffix->invoke( $this->interactivity, 'data-wp-interactive' );
- $this->assertSame( array( 'data-wp-interactive' ), $result );
+ // Should handle mix of different suffixes and unique IDs.
+ $html = '
+
';
+ $p = new WP_Interactivity_API_Directives_Processor( $html );
+ $p->next_tag();
+ $results = $get_directive_entries->invoke( $this->interactivity, $p, 'test' );
+ $this->assertCount( 4, $results );
+ $this->assertSame(
+ array(
+ 'namespace' => 'myPlugin',
+ 'value' => 'value1',
+ 'suffix' => 'suffix-a',
+ 'unique_id' => 'id-1',
+ ),
+ $results[0]
+ );
+ $this->assertSame(
+ array(
+ 'namespace' => 'myPlugin',
+ 'value' => 'value2',
+ 'suffix' => 'suffix-a',
+ 'unique_id' => 'id-2',
+ ),
+ $results[1]
+ );
+ $this->assertSame(
+ array(
+ 'namespace' => 'myPlugin',
+ 'value' => 'value3',
+ 'suffix' => 'suffix-b',
+ 'unique_id' => 'id-1',
+ ),
+ $results[2]
+ );
+ $this->assertSame(
+ array(
+ 'namespace' => 'myPlugin',
+ 'value' => 'value4',
+ 'suffix' => 'suffix-c',
+ 'unique_id' => 'id-1',
+ ),
+ $results[3]
+ );
- $result = $extract_prefix_and_suffix->invoke( $this->interactivity, 'data-wp-bind--src' );
- $this->assertSame( array( 'data-wp-bind', 'src' ), $result );
+ // Should handle unique ID with namespace.
+ $html = '
';
+ $p = new WP_Interactivity_API_Directives_Processor( $html );
+ $p->next_tag();
+ $results = $get_directive_entries->invoke( $this->interactivity, $p, 'test' );
+ $this->assertSame( 'my-namespace', $results[0]['namespace'] );
+ $this->assertSame( 'test value', $results[0]['value'] );
+ $this->assertSame( 'unique-id', $results[0]['unique_id'] );
- $result = $extract_prefix_and_suffix->invoke( $this->interactivity, 'data-wp-foo--and--bar' );
- $this->assertSame( array( 'data-wp-foo', 'and--bar' ), $result );
+ // Should handle multiple directives with different namespaces and unique IDs.
+ $html = '
+
';
+ $p = new WP_Interactivity_API_Directives_Processor( $html );
+ $p->next_tag();
+ $results = $get_directive_entries->invoke( $this->interactivity, $p, 'test' );
+ $this->assertCount( 2, $results );
+ $this->assertSame(
+ array(
+ 'namespace' => 'namespace-a',
+ 'value' => 'value1',
+ 'suffix' => null,
+ 'unique_id' => 'id-a',
+ ),
+ $results[0]
+ );
+ $this->assertSame(
+ array(
+ 'namespace' => 'namespace-b',
+ 'value' => 'value2',
+ 'suffix' => null,
+ 'unique_id' => 'id-b',
+ ),
+ $results[1]
+ );
+ // Should sort directives by suffix and uniqueId for stable ordering.
+ $html = '
+
';
+ $p = new WP_Interactivity_API_Directives_Processor( $html );
+ $p->next_tag();
+ $results = $get_directive_entries->invoke( $this->interactivity, $p, 'test' );
+ $this->assertCount( 6, $results );
+ $this->assertEquals(
+ array(
+ array( null, null ),
+ array( null, 'a' ),
+ array( null, 'z' ),
+ array( 'a', null ),
+ array( 'b', 'a' ),
+ array( 'b', 'z' ),
+ ),
+ array_map(
+ function ( $d ) {
+ return array( $d['suffix'], $d['unique_id'] );
+ },
+ $results
+ )
+ );
}
/**
@@ -915,8 +1217,6 @@ public function test_process_directives_process_the_directives_in_the_correct_or
*
* @dataProvider data_html_with_unbalanced_tags
*
- * @expectedIncorrectUsage WP_Interactivity_API::_process_directives
- *
* @param string $html HTML containing unbalanced tags and also a directive.
*/
public function test_process_directives_doesnt_change_html_if_contains_unbalanced_tags( $html ) {
@@ -1078,10 +1378,10 @@ public function test_process_directives_does_not_change_inner_html_in_math() {
/**
* Invokes the private `evaluate` method of WP_Interactivity_API class.
*
- * @param string $directive_value The directive attribute value to evaluate.
+ * @param string $entry The entry array containing namespace, value, suffix, and unique ID.
* @return mixed The result of the evaluate method.
*/
- private function evaluate( $directive_value ) {
+ private function evaluate( $entry ) {
/*
* The global WP_Interactivity_API instance is momentarily replaced to
* make global functions like `wp_interactivity_state` and
@@ -1096,7 +1396,7 @@ private function evaluate( $directive_value ) {
$evaluate->setAccessible( true );
}
- $result = $evaluate->invokeArgs( $this->interactivity, array( $directive_value ) );
+ $result = $evaluate->invokeArgs( $this->interactivity, array( $entry ) );
// Restore the original WP_Interactivity_API instance.
$wp_interactivity = $wp_interactivity_prev;
@@ -1142,24 +1442,55 @@ public function offsetUnset( $offset ): void {}
'otherPlugin' => array( 'key' => 'otherPlugin-context' ),
)
);
- $this->set_internal_namespace_stack( 'myPlugin' );
+ $default_ns = 'myPlugin';
+ $this->set_internal_namespace_stack( $default_ns );
- $result = $this->evaluate( 'state.key' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => $default_ns,
+ 'value' => 'state.key',
+ )
+ );
$this->assertSame( 'myPlugin-state', $result );
- $result = $this->evaluate( 'context.key' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => $default_ns,
+ 'value' => 'context.key',
+ )
+ );
$this->assertSame( 'myPlugin-context', $result );
- $result = $this->evaluate( 'otherPlugin::state.key' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => 'otherPlugin',
+ 'value' => 'state.key',
+ )
+ );
$this->assertSame( 'otherPlugin-state', $result );
- $result = $this->evaluate( 'otherPlugin::context.key' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => 'otherPlugin',
+ 'value' => 'context.key',
+ )
+ );
$this->assertSame( 'otherPlugin-context', $result );
- $result = $this->evaluate( 'state.obj.prop' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => $default_ns,
+ 'value' => 'state.obj.prop',
+ )
+ );
$this->assertSame( 'object property', $result );
- $result = $this->evaluate( 'state.arrAccess.1' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => $default_ns,
+ 'value' => 'state.arrAccess.1',
+ )
+ );
$this->assertSame( '1', $result );
}
@@ -1180,18 +1511,39 @@ public function test_evaluate_value_negation() {
'otherPlugin' => array( 'key' => 'otherPlugin-context' ),
)
);
- $this->set_internal_namespace_stack( 'myPlugin' );
+ $default_ns = 'myPlugin';
+ $this->set_internal_namespace_stack( $default_ns );
- $result = $this->evaluate( '!state.key' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => $default_ns,
+ 'value' => '!state.key',
+ )
+ );
$this->assertFalse( $result );
- $result = $this->evaluate( '!context.key' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => $default_ns,
+ 'value' => '!context.key',
+ )
+ );
$this->assertFalse( $result );
- $result = $this->evaluate( 'otherPlugin::!state.key' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => 'otherPlugin',
+ 'value' => '!state.key',
+ )
+ );
$this->assertFalse( $result );
- $result = $this->evaluate( 'otherPlugin::!context.key' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => 'otherPlugin',
+ 'value' => '!context.key',
+ )
+ );
$this->assertFalse( $result );
}
@@ -1212,18 +1564,39 @@ public function test_evaluate_value_negation_non_existent_path() {
'otherPlugin' => array(),
)
);
- $this->set_internal_namespace_stack( 'myPlugin' );
+ $default_ns = 'myPlugin';
+ $this->set_internal_namespace_stack( $default_ns );
- $result = $this->evaluate( '!state.missing' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => $default_ns,
+ 'value' => '!state.missing',
+ )
+ );
$this->assertTrue( $result );
- $result = $this->evaluate( '!context.missing' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => $default_ns,
+ 'value' => '!context.missing',
+ )
+ );
$this->assertTrue( $result );
- $result = $this->evaluate( 'otherPlugin::!state.deeply.nested.missing' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => 'otherPlugin',
+ 'value' => '!state.deeply.nested.missing',
+ )
+ );
$this->assertTrue( $result );
- $result = $this->evaluate( 'otherPlugin::!context.deeply.nested.missing' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => 'otherPlugin',
+ 'value' => '!context.deeply.nested.missing',
+ )
+ );
$this->assertTrue( $result );
}
@@ -1243,24 +1616,55 @@ public function test_evaluate_non_existent_path() {
'otherPlugin' => array( 'key' => 'otherPlugin-context' ),
)
);
- $this->set_internal_namespace_stack( 'myPlugin' );
+ $default_ns = 'myPlugin';
+ $this->set_internal_namespace_stack( $default_ns );
- $result = $this->evaluate( 'state.nonExistentKey' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => $default_ns,
+ 'value' => 'state.nonExistentKey',
+ )
+ );
$this->assertNull( $result );
- $result = $this->evaluate( 'context.nonExistentKey' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => $default_ns,
+ 'value' => 'context.nonExistentKey',
+ )
+ );
$this->assertNull( $result );
- $result = $this->evaluate( 'otherPlugin::state.nonExistentKey' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => 'otherPlugin',
+ 'value' => 'state.nonExistentKey',
+ )
+ );
$this->assertNull( $result );
- $result = $this->evaluate( 'otherPlugin::context.nonExistentKey' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => 'otherPlugin',
+ 'value' => 'context.nonExistentKey',
+ )
+ );
$this->assertNull( $result );
- $result = $this->evaluate( ' state.key' ); // Extra space.
+ $result = $this->evaluate(
+ array(
+ 'namespace' => $default_ns,
+ 'value' => ' state.key', // Extra space.
+ )
+ );
$this->assertNull( $result );
- $result = $this->evaluate( 'otherPlugin:: state.key' ); // Extra space.
+ $result = $this->evaluate(
+ array(
+ 'namespace' => 'otherPlugin',
+ 'value' => ' state.key', // Extra space.
+ )
+ );
$this->assertNull( $result );
}
@@ -1294,18 +1698,39 @@ public function test_evaluate_nested_value() {
),
)
);
- $this->set_internal_namespace_stack( 'myPlugin' );
+ $default_ns = 'myPlugin';
+ $this->set_internal_namespace_stack( $default_ns );
- $result = $this->evaluate( 'state.nested.key' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => $default_ns,
+ 'value' => 'state.nested.key',
+ )
+ );
$this->assertSame( 'myPlugin-state-nested', $result );
- $result = $this->evaluate( 'context.nested.key' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => $default_ns,
+ 'value' => 'context.nested.key',
+ )
+ );
$this->assertSame( 'myPlugin-context-nested', $result );
- $result = $this->evaluate( 'otherPlugin::state.nested.key' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => 'otherPlugin',
+ 'value' => 'state.nested.key',
+ )
+ );
$this->assertSame( 'otherPlugin-state-nested', $result );
- $result = $this->evaluate( 'otherPlugin::context.nested.key' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => 'otherPlugin',
+ 'value' => 'context.nested.key',
+ )
+ );
$this->assertSame( 'otherPlugin-context-nested', $result );
}
@@ -1321,13 +1746,28 @@ public function test_evaluate_unvalid_namespaces() {
$this->set_internal_context_stack( array() );
$this->set_internal_namespace_stack();
- $result = $this->evaluate( 'path', 'null' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => 'null',
+ 'value' => 'path',
+ )
+ );
$this->assertNull( $result );
- $result = $this->evaluate( 'path', '' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => '',
+ 'value' => 'path',
+ )
+ );
$this->assertNull( $result );
- $result = $this->evaluate( 'path', '{}' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => '{}',
+ 'value' => 'path',
+ )
+ );
$this->assertNull( $result );
}
@@ -1365,7 +1805,12 @@ public function test_evaluate_derived_state() {
);
$this->set_internal_namespace_stack( 'myPlugin' );
- $result = $this->evaluate( 'state.derived' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => 'myPlugin',
+ 'value' => 'state.derived',
+ )
+ );
$this->assertSame( "Derived state: myPlugin-state\nDerived context: myPlugin-context", $result );
}
@@ -1408,7 +1853,12 @@ public function test_evaluate_derived_state_accessing_different_namespace() {
);
$this->set_internal_namespace_stack( 'myPlugin' );
- $result = $this->evaluate( 'state.derived' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => 'myPlugin',
+ 'value' => 'state.derived',
+ )
+ );
$this->assertSame( "Derived state: otherPlugin-state\nDerived context: otherPlugin-context", $result );
}
@@ -1451,7 +1901,12 @@ public function test_evaluate_derived_state_defined_in_different_namespace() {
);
$this->set_internal_namespace_stack( 'myPlugin' );
- $result = $this->evaluate( 'otherPlugin::state.derived' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => 'otherPlugin',
+ 'value' => 'state.derived',
+ )
+ );
$this->assertSame( "Derived state: otherPlugin-state\nDerived context: otherPlugin-context", $result );
}
@@ -1475,7 +1930,12 @@ public function test_evaluate_derived_state_that_throws() {
$this->set_internal_context_stack();
$this->set_internal_namespace_stack( 'myPlugin' );
- $result = $this->evaluate( 'state.derivedThatThrows' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => 'myPlugin',
+ 'value' => 'state.derivedThatThrows',
+ )
+ );
$this->assertNull( $result );
}
@@ -1498,7 +1958,12 @@ public function test_evaluate_derived_state_intermediate() {
$this->set_internal_context_stack();
$this->set_internal_namespace_stack( 'myPlugin' );
- $result = $this->evaluate( 'state.derivedState.property' );
+ $result = $this->evaluate(
+ array(
+ 'namespace' => 'myPlugin',
+ 'value' => 'state.derivedState.property',
+ )
+ );
$this->assertSame( 'value', $result );
}