diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 129148fc194f6..cb7e82696845d 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -3468,3 +3468,153 @@ function wp_remove_surrounding_empty_script_tags( $contents ) { ); } } + +/** + * Return the corresponding JavaScript `dataset` name for an attribute + * if it represents a custom data attribute, or `null` if not. + * + * Custom data attributes appear in an element's `dataset` property in a + * browser, but there's a specific way the names are translated from HTML + * into JavaScript. This function indicates how the name would appear in + * JavaScript if a browser would recognize it as a custom data attribute. + * + * Example: + * + * // Dash-letter pairs turn into capital letters. + * 'postId' === wp_js_dataset_name( 'data-post-id' ); + * 'Before' === wp_js_dataset_name( 'data--before' ); + * '-One--Two---' === wp_js_dataset_name( 'data---one---two---' ); + * + * // Not every attribute name will be interpreted as a custom data attribute. + * null === wp_js_dataset_name( 'post-id' ); + * null === wp_js_dataset_name( 'data' ); + * + * // Some very surprising names will; for example, a property whose name is the empty string. + * '' === wp_js_dataset_name( 'data-' ); + * 0 === strlen( wp_js_dataset_name( 'data-' ) ); + * + * @since 6.9.0 + * + * @see https://html.spec.whatwg.org/#concept-domstringmap-pairs + * @see wp_html_custom_data_attribute_name() + * + * @param string $html_attribute_name Raw attribute name as found in the source HTML. + * @return string|null Transformed `dataset` name, if interpretable as a custom data attribute, else `null`. + */ +function wp_js_dataset_name( string $html_attribute_name ): ?string { + if ( 0 !== substr_compare( $html_attribute_name, 'data-', 0, 5, true ) ) { + return null; + } + + $end = strlen( $html_attribute_name ); + + /* + * If it contains characters which would end the attribute name parsing then + * something else is wrong and this contains more than just an attribute name. + */ + if ( ( $end - 5 ) !== strcspn( $html_attribute_name, "=/> \t\f\r\n", 5 ) ) { + return null; + } + + /* + * > For each name in list, for each U+002D HYPHEN-MINUS character (-) + * > in the name that is followed by an ASCII lower alpha, remove the + * > U+002D HYPHEN-MINUS character (-) and replace the character that + * > followed it by the same character converted to ASCII uppercase. + * + * @link https://html.spec.whatwg.org/#concept-domstringmap-pairs + */ + $custom_name = ''; + $at = 5; + $was_at = $at; + + while ( $at < $end ) { + $next_dash_at = strpos( $html_attribute_name, '-', $at ); + if ( false === $next_dash_at || $next_dash_at === $end - 1 ) { + break; + } + + // Transform `-a` to `A`, for example. + $c = $html_attribute_name[ $next_dash_at + 1 ]; + if ( ( $c >= 'A' && $c <= 'Z' ) || ( $c >= 'a' && $c <= 'z' ) ) { + $prefix = substr( $html_attribute_name, $was_at, $next_dash_at - $was_at ); + $custom_name .= strtolower( $prefix ); + $custom_name .= strtoupper( $c ); + $at = $next_dash_at + 2; + $was_at = $at; + continue; + } + + $at = $next_dash_at + 1; + } + + // If nothing has been added it means there are no dash-letter pairs; return the name as-is. + return '' === $custom_name + ? strtolower( substr( $html_attribute_name, 5 ) ) + : ( $custom_name . strtolower( substr( $html_attribute_name, $was_at ) ) ); +} + +/** + * Returns a corresponding HTML attribute name for the given name, + * if that name were found in a JS element’s `dataset` property. + * + * Example: + * + * 'data-post-id' === wp_html_custom_data_attribute_name( 'postId' ); + * 'data--before' === wp_html_custom_data_attribute_name( 'Before' ); + * 'data---one---two---' === wp_html_custom_data_attribute_name( '-One--Two---' ); + * + * // Not every attribute name will be interpreted as a custom data attribute. + * null === wp_html_custom_data_attribute_name( '/not-an-attribute/' ); + * null === wp_html_custom_data_attribute_name( 'no spaces' ); + * + * // Some very surprising names will; for example, a property whose name is the empty string. + * 'data-' === wp_html_custom_data_attribute_name( '' ); + * + * @since 6.9.0 + * + * @see https://html.spec.whatwg.org/#concept-domstringmap-pairs + * @see wp_js_dataset_name() + * + * @param string $js_dataset_name Name of JS `dataset` property to transform. + * @return string|null Corresponding name of an HTML custom data attribute for the given dataset name, + * if possible to represent in HTML, otherwise `null`. + */ +function wp_html_custom_data_attribute_name( string $js_dataset_name ): ?string { + $end = strlen( $js_dataset_name ); + if ( 0 === $end ) { + return 'data-'; + } + + /* + * If it contains characters which would end the attribute name parsing then + * something it’s not possible to represent this in HTML. + */ + if ( strcspn( $js_dataset_name, "=/> \t\f\r\n" ) !== $end ) { + return null; + } + + $html_name = 'data-'; + $at = 0; + $was_at = $at; + + while ( $at < $end ) { + $next_upper_after = strcspn( $js_dataset_name, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', $at ); + $next_upper_at = $at + $next_upper_after; + if ( $next_upper_at >= $end ) { + break; + } + + $prefix = substr( $js_dataset_name, $was_at, $next_upper_at - $was_at ); + $html_name .= strtolower( $prefix ); + $html_name .= '-' . strtolower( $js_dataset_name[ $next_upper_at ] ); + $at = $next_upper_at + 1; + $was_at = $at; + } + + if ( $was_at < $end ) { + $html_name .= strtolower( substr( $js_dataset_name, $was_at ) ); + } + + return $html_name; +} diff --git a/tests/phpunit/tests/js-interop.php b/tests/phpunit/tests/js-interop.php new file mode 100644 index 0000000000000..91b6a1a95045c --- /dev/null +++ b/tests/phpunit/tests/js-interop.php @@ -0,0 +1,131 @@ +assertTrue( true, 'This test only applies to the reverse transformation.' ); + return; + } + + $transformed_name = wp_js_dataset_name( $attribute_name ); + + if ( isset( $dataset_name ) ) { + $this->assertNotNull( + $transformed_name, + "Failed to recognize '{$attribute_name}' as a custom data attribute." + ); + + $this->assertSame( + $dataset_name, + $transformed_name, + 'Improperly transformed custom data attribute name.' + ); + } else { + $this->assertNull( + $transformed_name, + "Should not have identified '{$attribute_name}' as a custom data attribute." + ); + } + } + + /** + * Ensures proper transformation from JS dataset name to HTML custom attribute name. + * + * @ticket 61501 + * + * @dataProvider data_possible_custom_data_attributes_and_transformed_names + * + * @param string|null $attribute_name Raw HTML attribute name, if representable. + * @param string|null $dataset_name Transformed attribute name, or `null` if not a custom data attribute. + */ + public function test_transforms_dataset_to_proper_html_attribute_name( ?string $attribute_name, ?string $dataset_name ) { + if ( ! isset( $dataset_name ) ) { + // Skipping leaves a warning but this test data doesn’t apply to this side of the transformer. + $this->assertTrue( true, 'This test only applies to the reverse transformation.' ); + return; + } + + $transformed_name = wp_html_custom_data_attribute_name( $dataset_name ); + + if ( isset( $attribute_name ) ) { + $this->assertNotNull( + $transformed_name, + "Failed to recognize '{$dataset_name}' as a representable dataset property." + ); + + $this->assertSame( + strtolower( $attribute_name ), + $transformed_name, + 'Improperly transformed dataset property name.' + ); + } else { + $this->assertNull( + $transformed_name, + "Should not have identified '{$dataset_name}' as a representable dataset property." + ); + } + } + + /** + * Data provider. + * + * @return array[]. + */ + public static function data_possible_custom_data_attributes_and_transformed_names() { + return array( + // Non-custom-data attributes. + 'Normal attribute' => array( 'post-id', null ), + 'Single word' => array( 'id', null ), + + // Invalid HTML attribute names. + 'Contains spaces' => array( 'no spaces', null ), + 'Contains solidus' => array( 'one/more/name', null ), + + // Unrepresentable dataset names. + 'Dataset contains spaces' => array( null, 'one two' ), + 'Dataset contains solidus' => array( null, 'no/more/names' ), + + // Normative custom data attributes. + 'Normal custom data attribute' => array( 'data-post-id', 'postId' ), + 'Leading dash' => array( 'data--before', 'Before' ), + 'Trailing dash' => array( 'data-after-', 'after-' ), + 'Double-dashes' => array( 'data-wp-bind--enabled', 'wpBind-Enabled' ), + 'Double-dashes everywhere' => array( 'data--one--two--', 'One-Two--' ), + 'Triple-dashes' => array( 'data---one---two---', '-One--Two---' ), + + // Unexpected but recognized custom data attributes. + 'Only comprising a prefix' => array( 'data-', '' ), + 'With upper case ASCII' => array( 'data-Post-ID', 'postId' ), + 'With medial upper casing' => array( 'data-uPPer-cAsE', 'upperCase' ), + 'With Unicode whitespace' => array( "data-\u{2003}", "\u{2003}" ), + 'With Emoji' => array( 'data-🐄-pasture', '🐄Pasture' ), + 'Brackets and colon' => array( 'data-[wish:granted]', '[wish:granted]' ), + + // Pens and Pencils: a collection of interesting combinations of dash and underscore. + 'data-pens-and-pencils' => array( 'data-pens-and-pencils', 'pensAndPencils' ), + 'data-pens--and--pencils' => array( 'data-pens--and--pencils', 'pens-And-Pencils' ), + 'data--pens--and--pencils' => array( 'data--pens--and--pencils', 'Pens-And-Pencils' ), + 'data---pens---and---pencils' => array( 'data---pens---and---pencils', '-Pens--And--Pencils' ), + 'data-pens-and-pencils-' => array( 'data-pens-and-pencils-', 'pensAndPencils-' ), + 'data-pens-and-pencils--' => array( 'data-pens-and-pencils--', 'pensAndPencils--' ), + 'data-pens_and_pencils__' => array( 'data-pens_and_pencils__', 'pens_and_pencils__' ), + ); + } +}