Skip to content

Commit d9f2e6a

Browse files
committed
Introduce wp_js_dataset_name() for custom data attributes.
1 parent 4892d46 commit d9f2e6a

File tree

3 files changed

+290
-0
lines changed

3 files changed

+290
-0
lines changed

src/wp-includes/js-interop.php

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
<?php
2+
3+
/**
4+
* Core JavaScript Interoperability functions.
5+
*
6+
* @package WordPress
7+
* @subpackage JS-Interop
8+
*/
9+
10+
/**
11+
* Return the corresponding JavaScript `dataset` name for an attribute
12+
* if it represents a custom data attribute, or `null` if not.
13+
*
14+
* Custom data attributes appear in an element's `dataset` property in a
15+
* browser, but there's a specific way the names are translated from HTML
16+
* into JavaScript. This function indicates how the name would appear in
17+
* JavaScript if a browser would recognize it as a custom data attribute.
18+
*
19+
* Example:
20+
*
21+
* // Dash-letter pairs turn into capital letters.
22+
* 'postId' === wp_js_dataset_name( 'data-post-id' );
23+
* 'Before' === wp_js_dataset_name( 'data--before' );
24+
* '-One--Two---' === wp_js_dataset_name( 'data---one---two---' );
25+
*
26+
* // Not every attribute name will be interpreted as a custom data attribute.
27+
* null === wp_js_dataset_name( 'post-id' );
28+
* null === wp_js_dataset_name( 'data' );
29+
*
30+
* // Some very surprising names will; for example, a property whose name is the empty string.
31+
* '' === wp_js_dataset_name( 'data-' );
32+
* 0 === strlen( wp_js_dataset_name( 'data-' ) );
33+
*
34+
* @since 6.9.0
35+
*
36+
* @see https://html.spec.whatwg.org/#concept-domstringmap-pairs
37+
* @see wp_html_custom_data_attribute_name()
38+
*
39+
* @param string $html_attribute_name Raw attribute name as found in the source HTML.
40+
* @return string|null Transformed `dataset` name, if interpretable as a custom data attribute, else `null`.
41+
*/
42+
function wp_js_dataset_name( string $html_attribute_name ): ?string {
43+
if ( 0 !== substr_compare( $html_attribute_name, 'data-', 0, 5, true ) ) {
44+
return null;
45+
}
46+
47+
$end = strlen( $html_attribute_name );
48+
49+
/*
50+
* If it contains characters which would end the attribute name parsing then
51+
* something else is wrong and this contains more than just an attribute name.
52+
*/
53+
if ( ( $end - 5 ) !== strcspn( $html_attribute_name, "=/> \t\f\r\n", 5 ) ) {
54+
return null;
55+
}
56+
57+
/*
58+
* > For each name in list, for each U+002D HYPHEN-MINUS character (-)
59+
* > in the name that is followed by an ASCII lower alpha, remove the
60+
* > U+002D HYPHEN-MINUS character (-) and replace the character that
61+
* > followed it by the same character converted to ASCII uppercase.
62+
*
63+
* @link https://html.spec.whatwg.org/#concept-domstringmap-pairs
64+
*/
65+
$custom_name = '';
66+
$at = 5;
67+
$was_at = $at;
68+
69+
while ( $at < $end ) {
70+
$next_dash_at = strpos( $html_attribute_name, '-', $at );
71+
if ( false === $next_dash_at || $next_dash_at === $end - 1 ) {
72+
break;
73+
}
74+
75+
// Transform `-a` to `A`, for example.
76+
$c = $html_attribute_name[ $next_dash_at + 1 ];
77+
if ( ( $c >= 'A' && $c <= 'Z' ) || ( $c >= 'a' && $c <= 'z' ) ) {
78+
$prefix = substr( $html_attribute_name, $was_at, $next_dash_at - $was_at );
79+
$custom_name .= strtolower( $prefix );
80+
$custom_name .= strtoupper( $c );
81+
$at = $next_dash_at + 2;
82+
$was_at = $at;
83+
continue;
84+
}
85+
86+
$at = $next_dash_at + 1;
87+
}
88+
89+
// If nothing has been added it means there are no dash-letter pairs; return the name as-is.
90+
return '' === $custom_name
91+
? strtolower( substr( $html_attribute_name, 5 ) )
92+
: ( $custom_name . strtolower( substr( $html_attribute_name, $was_at ) ) );
93+
}
94+
95+
/**
96+
* Returns a corresponding HTML attribute name for the given name,
97+
* if that name were found in a JS element’s `dataset` property.
98+
*
99+
* Example:
100+
*
101+
* 'data-post-id' === wp_html_custom_data_attribute_name( 'postId' );
102+
* 'data--before' === wp_html_custom_data_attribute_name( 'Before' );
103+
* 'data---one---two---' === wp_html_custom_data_attribute_name( '-One--Two---' );
104+
*
105+
* // Not every attribute name will be interpreted as a custom data attribute.
106+
* null === wp_html_custom_data_attribute_name( '/not-an-attribute/' );
107+
* null === wp_html_custom_data_attribute_name( 'no spaces' );
108+
*
109+
* // Some very surprising names will; for example, a property whose name is the empty string.
110+
* 'data-' === wp_html_custom_data_attribute_name( '' );
111+
*
112+
* @since 6.9.0
113+
*
114+
* @see https://html.spec.whatwg.org/#concept-domstringmap-pairs
115+
* @see wp_js_dataset_name()
116+
*
117+
* @param string $js_dataset_name Name of JS `dataset` property to transform.
118+
* @return string|null Corresponding name of an HTML custom data attribute for the given dataset name,
119+
* if possible to represent in HTML, otherwise `null`.
120+
*/
121+
function wp_html_custom_data_attribute_name( string $js_dataset_name ): ?string {
122+
$end = strlen( $js_dataset_name );
123+
if ( 0 === $end ) {
124+
return 'data-';
125+
}
126+
127+
/*
128+
* If it contains characters which would end the attribute name parsing then
129+
* something it’s not possible to represent this in HTML.
130+
*/
131+
if ( strcspn( $js_dataset_name, "=/> \t\f\r\n" ) !== $end ) {
132+
return null;
133+
}
134+
135+
$html_name = 'data-';
136+
$at = 0;
137+
$was_at = $at;
138+
139+
while ( $at < $end ) {
140+
$next_upper_after = strcspn( $js_dataset_name, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', $at );
141+
$next_upper_at = $at + $next_upper_after;
142+
if ( $next_upper_at >= $end ) {
143+
break;
144+
}
145+
146+
$prefix = substr( $js_dataset_name, $was_at, $next_upper_at - $was_at );
147+
$html_name .= strtolower( $prefix );
148+
$html_name .= '-' . strtolower( $js_dataset_name[ $next_upper_at ] );
149+
$at = $next_upper_at + 1;
150+
$was_at = $at;
151+
}
152+
153+
if ( $was_at < $end ) {
154+
$html_name .= strtolower( substr( $js_dataset_name, $was_at ) );
155+
}
156+
157+
return $html_name;
158+
}

src/wp-settings.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@
239239
require ABSPATH . WPINC . '/kses.php';
240240
require ABSPATH . WPINC . '/cron.php';
241241
require ABSPATH . WPINC . '/deprecated.php';
242+
require ABSPATH . WPINC . '/js-interop.php';
242243
require ABSPATH . WPINC . '/script-loader.php';
243244
require ABSPATH . WPINC . '/taxonomy.php';
244245
require ABSPATH . WPINC . '/class-wp-taxonomy.php';

tests/phpunit/tests/js-interop.php

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<?php
2+
/**
3+
* Tests verifying behaviors for supporting interoperability with JavaScript.
4+
*
5+
* @package WordPress
6+
* @group js-interop
7+
*/
8+
class Tests_JS_Interop extends WP_UnitTestCase {
9+
/**
10+
* Ensures proper recognition of a data attribute and how to transform its
11+
* name into what JavaScript code would read from an element's `dataset`.
12+
*
13+
* @ticket 61501
14+
*
15+
* @dataProvider data_possible_custom_data_attributes_and_transformed_names
16+
*
17+
* @param string|null $attribute_name Raw HTML attribute name, if representable.
18+
* @param string|null $dataset_name Transformed attribute name, or `null` if not a custom data attribute.
19+
*/
20+
public function test_transforms_custom_attributes_to_proper_dataset_name( ?string $attribute_name, ?string $dataset_name ) {
21+
if ( ! isset( $attribute_name ) ) {
22+
// Skipping leaves a warning but this test data doesn’t apply to this side of the transformer.
23+
$this->assertTrue( true, 'This test only applies to the reverse transformation.' );
24+
return;
25+
}
26+
27+
$transformed_name = wp_js_dataset_name( $attribute_name );
28+
29+
if ( isset( $dataset_name ) ) {
30+
$this->assertNotNull(
31+
$transformed_name,
32+
"Failed to recognize '{$attribute_name}' as a custom data attribute."
33+
);
34+
35+
$this->assertSame(
36+
$dataset_name,
37+
$transformed_name,
38+
'Improperly transformed custom data attribute name.'
39+
);
40+
} else {
41+
$this->assertNull(
42+
$transformed_name,
43+
"Should not have identified '{$attribute_name}' as a custom data attribute."
44+
);
45+
}
46+
}
47+
48+
/**
49+
* Ensures proper transformation from JS dataset name to HTML custom attribute name.
50+
*
51+
* @ticket 61501
52+
*
53+
* @dataProvider data_possible_custom_data_attributes_and_transformed_names
54+
*
55+
* @param string|null $attribute_name Raw HTML attribute name, if representable.
56+
* @param string|null $dataset_name Transformed attribute name, or `null` if not a custom data attribute.
57+
*/
58+
public function test_transforms_dataset_to_proper_html_attribute_name( ?string $attribute_name, ?string $dataset_name ) {
59+
if ( ! isset( $dataset_name ) ) {
60+
// Skipping leaves a warning but this test data doesn’t apply to this side of the transformer.
61+
$this->assertTrue( true, 'This test only applies to the reverse transformation.' );
62+
return;
63+
}
64+
65+
$transformed_name = wp_html_custom_data_attribute_name( $dataset_name );
66+
67+
if ( isset( $attribute_name ) ) {
68+
$this->assertNotNull(
69+
$transformed_name,
70+
"Failed to recognize '{$dataset_name}' as a representable dataset property."
71+
);
72+
73+
$this->assertSame(
74+
strtolower( $attribute_name ),
75+
$transformed_name,
76+
'Improperly transformed dataset property name.'
77+
);
78+
} else {
79+
$this->assertNull(
80+
$transformed_name,
81+
"Should not have identified '{$dataset_name}' as a representable dataset property."
82+
);
83+
}
84+
}
85+
86+
/**
87+
* Data provider.
88+
*
89+
* @return array[].
90+
*/
91+
public static function data_possible_custom_data_attributes_and_transformed_names() {
92+
return array(
93+
// Non-custom-data attributes.
94+
'Normal attribute' => array( 'post-id', null ),
95+
'Single word' => array( 'id', null ),
96+
97+
// Invalid HTML attribute names.
98+
'Contains spaces' => array( 'no spaces', null ),
99+
'Contains solidus' => array( 'one/more/name', null ),
100+
101+
// Unrepresentable dataset names.
102+
'Dataset contains spaces' => array( null, 'one two' ),
103+
'Dataset contains solidus' => array( null, 'no/more/names' ),
104+
105+
// Normative custom data attributes.
106+
'Normal custom data attribute' => array( 'data-post-id', 'postId' ),
107+
'Leading dash' => array( 'data--before', 'Before' ),
108+
'Trailing dash' => array( 'data-after-', 'after-' ),
109+
'Double-dashes' => array( 'data-wp-bind--enabled', 'wpBind-Enabled' ),
110+
'Double-dashes everywhere' => array( 'data--one--two--', 'One-Two--' ),
111+
'Triple-dashes' => array( 'data---one---two---', '-One--Two---' ),
112+
113+
// Unexpected but recognized custom data attributes.
114+
'Only comprising a prefix' => array( 'data-', '' ),
115+
'With upper case ASCII' => array( 'data-Post-ID', 'postId' ),
116+
'With medial upper casing' => array( 'data-uPPer-cAsE', 'upperCase' ),
117+
'With Unicode whitespace' => array( "data-\u{2003}", "\u{2003}" ),
118+
'With Emoji' => array( 'data-🐄-pasture', '🐄Pasture' ),
119+
'Brackets and colon' => array( 'data-[wish:granted]', '[wish:granted]' ),
120+
121+
// Pens and Pencils: a collection of interesting combinations of dash and underscore.
122+
'data-pens-and-pencils' => array( 'data-pens-and-pencils', 'pensAndPencils' ),
123+
'data-pens--and--pencils' => array( 'data-pens--and--pencils', 'pens-And-Pencils' ),
124+
'data--pens--and--pencils' => array( 'data--pens--and--pencils', 'Pens-And-Pencils' ),
125+
'data---pens---and---pencils' => array( 'data---pens---and---pencils', '-Pens--And--Pencils' ),
126+
'data-pens-and-pencils-' => array( 'data-pens-and-pencils-', 'pensAndPencils-' ),
127+
'data-pens-and-pencils--' => array( 'data-pens-and-pencils--', 'pensAndPencils--' ),
128+
'data-pens_and_pencils__' => array( 'data-pens_and_pencils__', 'pens_and_pencils__' ),
129+
);
130+
}
131+
}

0 commit comments

Comments
 (0)