Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions src/wp-includes/script-loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
131 changes: 131 additions & 0 deletions tests/phpunit/tests/js-interop.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php
/**
* Tests verifying behaviors for supporting interoperability with JavaScript.
*
* @package WordPress
* @group js-interop
*/
class Tests_JS_Interop extends WP_UnitTestCase {
/**
* Ensures proper recognition of a data attribute and how to transform its
* name into what JavaScript code would read from an element's `dataset`.
*
* @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_custom_attributes_to_proper_dataset_name( ?string $attribute_name, ?string $dataset_name ) {
if ( ! isset( $attribute_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_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__' ),
);
}
}
Loading