- Timestamp:
- 10/21/2025 12:07:16 PM (4 months ago)
- File:
-
- 1 edited
Legend:
- Unmodified
- Added
- Removed
-
trunk/src/wp-includes/interactivity-api/class-wp-interactivity-api.php
r61019 r61020 533 533 // Checks if there is a server directive processor registered for each directive. 534 534 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 ) ) { 553 537 continue; 554 538 } 555 list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name );539 $directive_prefix = 'data-wp-' . $parsed_directive['prefix']; 556 540 if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) { 557 541 $directives_prefixes[] = $directive_prefix; … … 630 614 * It returns null if the HTML is unbalanced because unbalanced HTML is 631 615 * 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 also633 * display a notice to the developerto 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. 634 618 */ 635 619 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' );640 620 return null; 641 621 } … … 652 632 * @since 6.6.0 Removed `default_namespace` and `context` arguments. 653 633 * @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. 656 637 * @return mixed|null The result of the evaluation. Null if the reference path doesn't exist or the namespace is falsy. 657 638 */ 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 663 643 if ( ! $ns || ! $path ) { 664 644 /* 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 ) ); 666 646 _doing_it_wrong( __METHOD__, $message, '6.6.0' ); 667 647 return null; … … 767 747 768 748 /** 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 782 760 * 783 761 * @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 ); 788 817 } 789 818 … … 835 864 836 865 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; 837 913 } 838 914 … … 918 994 } 919 995 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() ) 936 1006 ); 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; 945 1009 } 946 1010 … … 958 1022 private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) { 959 1023 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 ); 970 1031 971 1032 if ( … … 973 1034 ( 974 1035 false !== $result || 975 ( strlen( $ bound_attribute ) > 5 && '-' === $bound_attribute[4] )1036 ( strlen( $entry['suffix'] ) > 5 && '-' === $entry['suffix'][4] ) 976 1037 ) 977 1038 ) { … … 985 1046 if ( 986 1047 is_bool( $result ) && 987 ( strlen( $ bound_attribute ) > 5 && '-' === $bound_attribute[4] )1048 ( strlen( $entry['suffix'] ) > 5 && '-' === $entry['suffix'][4] ) 988 1049 ) { 989 1050 $result = $result ? 'true' : 'false'; 990 1051 } 991 $p->set_attribute( $ bound_attribute, $result );1052 $p->set_attribute( $entry['suffix'], $result ); 992 1053 } else { 993 $p->remove_attribute( $ bound_attribute);1054 $p->remove_attribute( $entry['suffix'] ); 994 1055 } 995 1056 } … … 1011 1072 if ( 'enter' === $mode ) { 1012 1073 $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 1016 1083 if ( empty( $class_name ) ) { 1017 1084 return; 1018 1085 } 1019 1086 1020 $attribute_value = $p->get_attribute( $attribute_name ); 1021 $result = $this->evaluate( $attribute_value ); 1087 $result = $this->evaluate( $entry ); 1022 1088 1023 1089 if ( $result ) { … … 1043 1109 private function data_wp_style_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) { 1044 1110 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'] ) { 1050 1115 continue; 1051 1116 } 1052 1117 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 : ''; 1057 1121 1058 1122 /* … … 1134 1198 private function data_wp_text_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) { 1135 1199 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 ); 1138 1213 1139 1214 /* … … 1264 1339 private function data_wp_each_processor( WP_Interactivity_API_Directives_Processor $p, string $mode, array &$tag_stack ) { 1265 1340 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 ); 1271 1352 1272 1353 // Gets the content between the template tags and leaves the cursor in the closer tag. … … 1301 1382 } 1302 1383 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 1309 1384 // Processes the inner content for each item of the array. 1310 1385 $processed_content = ''; … … 1313 1388 $this->context_stack[] = array_replace_recursive( 1314 1389 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 ) ) 1316 1391 ); 1317 1392 … … 1338 1413 $i = new WP_Interactivity_API_Directives_Processor( $processed_item ); 1339 1414 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'] ); 1341 1416 $i->next_balanced_tag_closer_tag(); 1342 1417 }
Note: See TracChangeset
for help on using the changeset viewer.