From 20c05c1091fcf308958a9431b87b55ea8fbfb74b Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Thu, 18 Sep 2025 15:57:20 -0400 Subject: [PATCH 1/3] Introduce wp_js_dataset_name() for custom data attributes. --- src/wp-includes/js-interop.php | 158 +++++++++++++++++++++++++++++ src/wp-settings.php | 1 + tests/phpunit/tests/js-interop.php | 131 ++++++++++++++++++++++++ 3 files changed, 290 insertions(+) create mode 100644 src/wp-includes/js-interop.php create mode 100644 tests/phpunit/tests/js-interop.php diff --git a/src/wp-includes/js-interop.php b/src/wp-includes/js-interop.php new file mode 100644 index 0000000000000..77d7334ef2b59 --- /dev/null +++ b/src/wp-includes/js-interop.php @@ -0,0 +1,158 @@ + \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/src/wp-settings.php b/src/wp-settings.php index b1f7a7cadcf4a..c88f87739d311 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -239,6 +239,7 @@ require ABSPATH . WPINC . '/kses.php'; require ABSPATH . WPINC . '/cron.php'; require ABSPATH . WPINC . '/deprecated.php'; +require ABSPATH . WPINC . '/js-interop.php'; require ABSPATH . WPINC . '/script-loader.php'; require ABSPATH . WPINC . '/taxonomy.php'; require ABSPATH . WPINC . '/class-wp-taxonomy.php'; 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__' ), + ); + } +} From ab66d7e679f7fdb29b89291f620f5863b944e51e Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Fri, 17 Oct 2025 18:04:58 -0700 Subject: [PATCH 2/3] Move dataset functions into script-loader. Co-authored-by: Weston Ruter --- src/wp-includes/js-interop.php | 158 ------------------------------ src/wp-includes/script-loader.php | 150 ++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 158 deletions(-) delete mode 100644 src/wp-includes/js-interop.php diff --git a/src/wp-includes/js-interop.php b/src/wp-includes/js-interop.php deleted file mode 100644 index 77d7334ef2b59..0000000000000 --- a/src/wp-includes/js-interop.php +++ /dev/null @@ -1,158 +0,0 @@ - \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/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; +} From 4fa1e2cc505c5fc722e05aa991fc0985e6bed42f Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Fri, 17 Oct 2025 18:08:46 -0700 Subject: [PATCH 3/3] Fixup: settings --- src/wp-settings.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/wp-settings.php b/src/wp-settings.php index c88f87739d311..b1f7a7cadcf4a 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -239,7 +239,6 @@ require ABSPATH . WPINC . '/kses.php'; require ABSPATH . WPINC . '/cron.php'; require ABSPATH . WPINC . '/deprecated.php'; -require ABSPATH . WPINC . '/js-interop.php'; require ABSPATH . WPINC . '/script-loader.php'; require ABSPATH . WPINC . '/taxonomy.php'; require ABSPATH . WPINC . '/class-wp-taxonomy.php';