Make WordPress Core


Ignore:
Timestamp:
10/21/2025 12:07:16 PM (10 months ago)
Author:
luisherranz
Message:

Interactivity API: Support unique IDs in server-side directives processing.

Server-side logic to support unique IDs in the Interactivity API directives to match what the client is doing (https://github.com/WordPress/gutenberg/pull/72161).

Props santosguillamot, darerodz, luisherranz.
Fixes #64106.

File:
1 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/interactivity-api/class-wp-interactivity-api.php

    r61019 r61020  
    533533                                        // Checks if there is a server directive processor registered for each directive.
    534534                                        foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) {
    535                                                 if ( ! preg_match(
    536                                                         /*
    537                                                          * This must align with the client-side regex used by the interactivity API.
    538                                                          * @see https://github.com/WordPress/gutenberg/blob/ca616014255efbb61f34c10917d52a2d86c1c660/packages/interactivity/src/vdom.ts#L20-L32
    539                                                          */
    540                                                         '/' .
    541                                                         '^data-wp-' .
    542                                                         // Match alphanumeric characters including hyphen-separated
    543                                                         // segments. It excludes underscore intentionally to prevent confusion.
    544                                                         // E.g., "custom-directive".
    545                                                         '([a-z0-9]+(?:-[a-z0-9]+)*)' .
    546                                                         // (Optional) Match '--' followed by any alphanumeric charachters. It
    547                                                         // excludes underscore intentionally to prevent confusion, but it can
    548                                                         // contain multiple hyphens. E.g., "--custom-prefix--with-more-info".
    549                                                         '(?:--([a-z0-9_-]+))?$' .
    550                                                         '/i',
    551                                                         $attribute_name
    552                                                 ) ) {
     535                                                $parsed_directive = $this->parse_directive_name( $attribute_name );
     536                                                if ( empty( $parsed_directive ) ) {
    553537                                                        continue;
    554538                                                }
    555                                                 list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name );
     539                                                $directive_prefix = 'data-wp-' . $parsed_directive['prefix'];
    556540                                                if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) {
    557541                                                        $directives_prefixes[] = $directive_prefix;
     
    630614                 * It returns null if the HTML is unbalanced because unbalanced HTML is
    631615                 * not safe to process. In that case, the Interactivity API runtime will
    632                  * update the HTML on the client side during the hydration. It will also
    633                  * display a notice to the developer to inform them about the issue.
     616                 * update the HTML on the client side during the hydration. It will display
     617                 * a notice to the developer in the console to inform them about the issue.
    634618                 */
    635619                if ( $unbalanced || 0 < count( $tag_stack ) ) {
    636                         $tag_errored = 0 < count( $tag_stack ) ? end( $tag_stack )[0] : $tag_name;
    637                         /* translators: %1s: Namespace processed, %2s: The tag that caused the error; could be any HTML tag.  */
    638                         $message = sprintf( __( 'Interactivity directives failed to process in "%1$s" due to a missing "%2$s" end tag.' ), end( $this->namespace_stack ), $tag_errored );
    639                         _doing_it_wrong( __METHOD__, $message, '6.6.0' );
    640620                        return null;
    641621                }
     
    652632         * @since 6.6.0 Removed `default_namespace` and `context` arguments.
    653633         * @since 6.6.0 Add support for derived state.
    654          *
    655          * @param string|true $directive_value The directive attribute value string or `true` when it's a boolean attribute.
     634         * @since 6.9.0 Recieve $entry as an argument instead of the directive value string.
     635         *
     636         * @param array $entry An array containing a whole directive entry with its namespace, value, suffix, or unique ID.
    656637         * @return mixed|null The result of the evaluation. Null if the reference path doesn't exist or the namespace is falsy.
    657638         */
    658         private function evaluate( $directive_value ) {
    659                 $default_namespace = end( $this->namespace_stack );
    660                 $context           = end( $this->context_stack );
    661 
    662                 list( $ns, $path ) = $this->extract_directive_value( $directive_value, $default_namespace );
     639        private function evaluate( $entry ) {
     640                $context                               = end( $this->context_stack );
     641                ['namespace' => $ns, 'value' => $path] = $entry;
     642
    663643                if ( ! $ns || ! $path ) {
    664644                        /* translators: %s: The directive value referenced. */
    665                         $message = sprintf( __( 'Namespace or reference path cannot be empty. Directive value referenced: %s' ), $directive_value );
     645                        $message = sprintf( __( 'Namespace or reference path cannot be empty. Directive value referenced: %s' ), json_encode( $entry ) );
    666646                        _doing_it_wrong( __METHOD__, $message, '6.6.0' );
    667647                        return null;
     
    767747
    768748        /**
    769          * Extracts the directive attribute name to separate and return the directive
    770          * prefix and an optional suffix.
    771          *
    772          * The suffix is the string after the first double hyphen and the prefix is
    773          * everything that comes before the suffix.
    774          *
    775          * Example:
    776          *
    777          *     extract_prefix_and_suffix( 'data-wp-interactive' )   => array( 'data-wp-interactive', null )
    778          *     extract_prefix_and_suffix( 'data-wp-bind--src' )     => array( 'data-wp-bind', 'src' )
    779          *     extract_prefix_and_suffix( 'data-wp-foo--and--bar' ) => array( 'data-wp-foo', 'and--bar' )
    780          *
    781          * @since 6.5.0
     749         * Parse the directive name to extract the following parts:
     750         * - Prefix: The main directive name without "data-wp-".
     751         * - Suffix: An optional suffix used during directive processing, extracted after the first double hyphen "--".
     752         * - Unique ID: An optional unique identifier, extracted after the first triple hyphen "---".
     753         *
     754         * This function has an equivalent version for the client side.
     755         * See `parseDirectiveName` in https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/src/vdom.ts.:
     756         *
     757         * See examples in the function unit tests `test_parse_directive_name`.
     758         *
     759         * @since 6.9.0
    782760         *
    783761         * @param string $directive_name The directive attribute name.
    784          * @return array An array containing the directive prefix and optional suffix.
    785          */
    786         private function extract_prefix_and_suffix( string $directive_name ): array {
    787                 return explode( '--', $directive_name, 2 );
     762         * @return array An array containing the directive prefix, optional suffix, and optional unique ID.
     763         */
     764        private function parse_directive_name( string $directive_name ): ?array {
     765                // Remove the first 8 characters (assumes "data-wp-" prefix)
     766                $name = substr( $directive_name, 8 );
     767
     768                // Check for invalid characters (anything not a-z, 0-9, -, or _)
     769                if ( preg_match( '/[^a-z0-9\-_]/i', $name ) ) {
     770                        return null;
     771                }
     772
     773                // Find the first occurrence of '--' to separate the prefix
     774                $suffix_index = strpos( $name, '--' );
     775
     776                if ( false === $suffix_index ) {
     777                        return array(
     778                                'prefix'    => $name,
     779                                'suffix'    => null,
     780                                'unique_id' => null,
     781                        );
     782                }
     783
     784                $prefix    = substr( $name, 0, $suffix_index );
     785                $remaining = substr( $name, $suffix_index );
     786
     787                // If remaining starts with '---' but not '----', it's a unique_id
     788                if ( '---' === substr( $remaining, 0, 3 ) && '-' !== ( $remaining[3] ?? '' ) ) {
     789                        return array(
     790                                'prefix'    => $prefix,
     791                                'suffix'    => null,
     792                                'unique_id' => '---' !== $remaining ? substr( $remaining, 3 ) : null,
     793                        );
     794                }
     795
     796                // Otherwise, remove the first two dashes for a potential suffix
     797                $suffix = substr( $remaining, 2 );
     798
     799                // Look for '---' in the suffix for a unique_id
     800                $unique_id_index = strpos( $suffix, '---' );
     801
     802                if ( false !== $unique_id_index && '-' !== ( $suffix[ $unique_id_index + 3 ] ?? '' ) ) {
     803                        $unique_id = substr( $suffix, $unique_id_index + 3 );
     804                        $suffix    = substr( $suffix, 0, $unique_id_index );
     805                        return array(
     806                                'prefix'    => $prefix,
     807                                'suffix'    => empty( $suffix ) ? null : $suffix,
     808                                'unique_id' => empty( $unique_id ) ? null : $unique_id,
     809                        );
     810                }
     811
     812                return array(
     813                        'prefix'    => $prefix,
     814                        'suffix'    => empty( $suffix ) ? null : $suffix,
     815                        'unique_id' => null,
     816                );
    788817        }
    789818
     
    835864
    836865                return array( $default_namespace, $directive_value );
     866        }
     867
     868        /**
     869         * Parse the HTML element and get all the valid directives with the given prefix.
     870         *
     871         * @since 6.9.0
     872         *
     873         * @param WP_Interactivity_API_Directives_Processor $p      The directives processor instance.
     874         * @param string                                    $prefix The directive prefix to filter by.
     875         * @return array An array of entries containing the directive namespace, value, suffix, and unique ID.
     876         */
     877        private function get_directive_entries( WP_Interactivity_API_Directives_Processor $p, string $prefix ) {
     878                $directive_attributes = $p->get_attribute_names_with_prefix( 'data-wp-' . $prefix );
     879                $entries              = array();
     880                foreach ( $directive_attributes as $attribute_name ) {
     881                        [ 'prefix' => $attr_prefix, 'suffix' => $suffix, 'unique_id' => $unique_id] = $this->parse_directive_name( $attribute_name );
     882                        // Ensure it is the desired directive.
     883                        if ( $prefix !== $attr_prefix ) {
     884                                continue;
     885                        }
     886                        list( $namespace, $value ) = $this->extract_directive_value( $p->get_attribute( $attribute_name ), end( $this->namespace_stack ) );
     887                        $entries[]                 = array(
     888                                'namespace' => $namespace,
     889                                'value'     => $value,
     890                                'suffix'    => $suffix,
     891                                'unique_id' => $unique_id,
     892                        );
     893                }
     894                // Sort directive entries to ensure stable ordering with the client.
     895                // Put nulls first, then sort by suffix and finally by uniqueIds.
     896                usort(
     897                        $entries,
     898                        function ( $a, $b ) {
     899                                $a_suffix = $a['suffix'] ?? '';
     900                                $b_suffix = $b['suffix'] ?? '';
     901                                if ( $a_suffix !== $b_suffix ) {
     902                                        return $a_suffix < $b_suffix ? -1 : 1;
     903                                }
     904                                $a_id = $a['unique_id'] ?? '';
     905                                $b_id = $b['unique_id'] ?? '';
     906                                if ( $a_id === $b_id ) {
     907                                        return 0;
     908                                }
     909                                return $a_id > $b_id ? 1 : -1;
     910                        }
     911                );
     912                return $entries;
    837913        }
    838914
     
    918994                }
    919995
    920                 $attribute_value = $p->get_attribute( 'data-wp-context' );
    921                 $namespace_value = end( $this->namespace_stack );
    922 
    923                 // Separates the namespace from the context JSON object.
    924                 list( $namespace_value, $decoded_json ) = is_string( $attribute_value ) && ! empty( $attribute_value )
    925                         ? $this->extract_directive_value( $attribute_value, $namespace_value )
    926                         : array( $namespace_value, null );
    927 
    928                 /*
    929                  * If there is a namespace, it adds a new context to the stack merging the
    930                  * previous context with the new one.
    931                  */
    932                 if ( is_string( $namespace_value ) ) {
    933                         $this->context_stack[] = array_replace_recursive(
    934                                 end( $this->context_stack ) !== false ? end( $this->context_stack ) : array(),
    935                                 array( $namespace_value => is_array( $decoded_json ) ? $decoded_json : array() )
     996                $entries = $this->get_directive_entries( $p, 'context' );
     997                $context = end( $this->context_stack ) !== false ? end( $this->context_stack ) : array();
     998                foreach ( $entries as $entry ) {
     999                        if ( null !== $entry['suffix'] ) {
     1000                                continue;
     1001                        }
     1002
     1003                        $context = array_replace_recursive(
     1004                                $context,
     1005                                array( $entry['namespace'] => is_array( $entry['value'] ) ? $entry['value'] : array() )
    9361006                        );
    937                 } else {
    938                         /*
    939                          * If there is no namespace, it pushes the current context to the stack.
    940                          * It needs to do so because the function pops out the current context
    941                          * from the stack whenever it finds a `data-wp-context`'s closing tag.
    942                          */
    943                         $this->context_stack[] = end( $this->context_stack );
    944                 }
     1007                }
     1008                $this->context_stack[] = $context;
    9451009        }
    9461010
     
    9581022        private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
    9591023                if ( 'enter' === $mode ) {
    960                         $all_bind_directives = $p->get_attribute_names_with_prefix( 'data-wp-bind--' );
    961 
    962                         foreach ( $all_bind_directives as $attribute_name ) {
    963                                 list( , $bound_attribute ) = $this->extract_prefix_and_suffix( $attribute_name );
    964                                 if ( empty( $bound_attribute ) ) {
    965                                         return;
    966                                 }
    967 
    968                                 $attribute_value = $p->get_attribute( $attribute_name );
    969                                 $result          = $this->evaluate( $attribute_value );
     1024                        $entries = $this->get_directive_entries( $p, 'bind' );
     1025                        foreach ( $entries as $entry ) {
     1026                                if ( empty( $entry['suffix'] ) || null !== $entry['unique_id'] ) {
     1027                                                return;
     1028                                }
     1029
     1030                                $result = $this->evaluate( $entry );
    9701031
    9711032                                if (
     
    9731034                                        (
    9741035                                                false !== $result ||
    975                                                 ( strlen( $bound_attribute ) > 5 && '-' === $bound_attribute[4] )
     1036                                                ( strlen( $entry['suffix'] ) > 5 && '-' === $entry['suffix'][4] )
    9761037                                        )
    9771038                                ) {
     
    9851046                                        if (
    9861047                                                is_bool( $result ) &&
    987                                                 ( strlen( $bound_attribute ) > 5 && '-' === $bound_attribute[4] )
     1048                                                ( strlen( $entry['suffix'] ) > 5 && '-' === $entry['suffix'][4] )
    9881049                                        ) {
    9891050                                                $result = $result ? 'true' : 'false';
    9901051                                        }
    991                                         $p->set_attribute( $bound_attribute, $result );
     1052                                        $p->set_attribute( $entry['suffix'], $result );
    9921053                                } else {
    993                                         $p->remove_attribute( $bound_attribute );
     1054                                        $p->remove_attribute( $entry['suffix'] );
    9941055                                }
    9951056                        }
     
    10111072                if ( 'enter' === $mode ) {
    10121073                        $all_class_directives = $p->get_attribute_names_with_prefix( 'data-wp-class--' );
    1013 
    1014                         foreach ( $all_class_directives as $attribute_name ) {
    1015                                 list( , $class_name ) = $this->extract_prefix_and_suffix( $attribute_name );
     1074                        $entries              = $this->get_directive_entries( $p, 'class' );
     1075                        foreach ( $entries as $entry ) {
     1076                                if ( empty( $entry['suffix'] ) ) {
     1077                                        continue;
     1078                                }
     1079                                $class_name = isset( $entry['unique_id'] ) && $entry['unique_id']
     1080                                        ? "{$entry['suffix']}---{$entry['unique_id']}"
     1081                                        : $entry['suffix'];
     1082
    10161083                                if ( empty( $class_name ) ) {
    10171084                                        return;
    10181085                                }
    10191086
    1020                                 $attribute_value = $p->get_attribute( $attribute_name );
    1021                                 $result          = $this->evaluate( $attribute_value );
     1087                                $result = $this->evaluate( $entry );
    10221088
    10231089                                if ( $result ) {
     
    10431109        private function data_wp_style_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
    10441110                if ( 'enter' === $mode ) {
    1045                         $all_style_attributes = $p->get_attribute_names_with_prefix( 'data-wp-style--' );
    1046 
    1047                         foreach ( $all_style_attributes as $attribute_name ) {
    1048                                 list( , $style_property ) = $this->extract_prefix_and_suffix( $attribute_name );
    1049                                 if ( empty( $style_property ) ) {
     1111                        $entries = $this->get_directive_entries( $p, 'style' );
     1112                        foreach ( $entries as $entry ) {
     1113                                $style_property = $entry['suffix'];
     1114                                if ( empty( $style_property ) || null !== $entry['unique_id'] ) {
    10501115                                        continue;
    10511116                                }
    10521117
    1053                                 $directive_attribute_value = $p->get_attribute( $attribute_name );
    1054                                 $style_property_value      = $this->evaluate( $directive_attribute_value );
    1055                                 $style_attribute_value     = $p->get_attribute( 'style' );
    1056                                 $style_attribute_value     = ( $style_attribute_value && ! is_bool( $style_attribute_value ) ) ? $style_attribute_value : '';
     1118                                $style_property_value  = $this->evaluate( $entry );
     1119                                $style_attribute_value = $p->get_attribute( 'style' );
     1120                                $style_attribute_value = ( $style_attribute_value && ! is_bool( $style_attribute_value ) ) ? $style_attribute_value : '';
    10571121
    10581122                                /*
     
    11341198        private function data_wp_text_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) {
    11351199                if ( 'enter' === $mode ) {
    1136                         $attribute_value = $p->get_attribute( 'data-wp-text' );
    1137                         $result          = $this->evaluate( $attribute_value );
     1200                        $entries     = $this->get_directive_entries( $p, 'text' );
     1201                        $valid_entry = null;
     1202                        // Get the first valid `data-wp-text` entry without suffix or unique ID.
     1203                        foreach ( $entries as $entry ) {
     1204                                if ( null === $entry['suffix'] && null === $entry['unique_id'] && ! empty( $entry['value'] ) ) {
     1205                                        $valid_entry = $entry;
     1206                                        break;
     1207                                }
     1208                        }
     1209                        if ( null === $valid_entry ) {
     1210                                return;
     1211                        }
     1212                        $result = $this->evaluate( $valid_entry );
    11381213
    11391214                        /*
     
    12641339        private function data_wp_each_processor( WP_Interactivity_API_Directives_Processor $p, string $mode, array &$tag_stack ) {
    12651340                if ( 'enter' === $mode && 'TEMPLATE' === $p->get_tag() ) {
    1266                         $attribute_name   = $p->get_attribute_names_with_prefix( 'data-wp-each' )[0];
    1267                         $extracted_suffix = $this->extract_prefix_and_suffix( $attribute_name );
    1268                         $item_name        = isset( $extracted_suffix[1] ) ? $this->kebab_to_camel_case( $extracted_suffix[1] ) : 'item';
    1269                         $attribute_value  = $p->get_attribute( $attribute_name );
    1270                         $result           = $this->evaluate( $attribute_value );
     1341                        $entries = $this->get_directive_entries( $p, 'each' );
     1342                        if ( count( $entries ) > 1 || empty( $entries ) ) {
     1343                                // There should be only one `data-wp-each` directive per template tag.
     1344                                return;
     1345                        }
     1346                        $entry = $entries[0];
     1347                        if ( null !== $entry['unique_id'] ) {
     1348                                return;
     1349                        }
     1350                        $item_name = isset( $entry['suffix'] ) ? $this->kebab_to_camel_case( $entry['suffix'] ) : 'item';
     1351                        $result    = $this->evaluate( $entry );
    12711352
    12721353                        // Gets the content between the template tags and leaves the cursor in the closer tag.
     
    13011382                        }
    13021383
    1303                         // Extracts the namespace from the directive attribute value.
    1304                         $namespace_value                = end( $this->namespace_stack );
    1305                         list( $namespace_value, $path ) = is_string( $attribute_value ) && ! empty( $attribute_value )
    1306                                 ? $this->extract_directive_value( $attribute_value, $namespace_value )
    1307                                 : array( $namespace_value, null );
    1308 
    13091384                        // Processes the inner content for each item of the array.
    13101385                        $processed_content = '';
     
    13131388                                $this->context_stack[] = array_replace_recursive(
    13141389                                        end( $this->context_stack ) !== false ? end( $this->context_stack ) : array(),
    1315                                         array( $namespace_value => array( $item_name => $item ) )
     1390                                        array( $entry['namespace'] => array( $item_name => $item ) )
    13161391                                );
    13171392
     
    13381413                                $i = new WP_Interactivity_API_Directives_Processor( $processed_item );
    13391414                                while ( $i->next_tag() ) {
    1340                                         $i->set_attribute( 'data-wp-each-child', $namespace_value . '::' . $path );
     1415                                        $i->set_attribute( 'data-wp-each-child', $entry['namespace'] . '::' . $entry['value'] );
    13411416                                        $i->next_balanced_tag_closer_tag();
    13421417                                }
Note: See TracChangeset for help on using the changeset viewer.