Skip to content
Open
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
124 changes: 124 additions & 0 deletions src/wp-includes/class-wp-scripts.php
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,7 @@ public function do_item( $handle, $group = false ) {
_print_scripts();
$this->reset();
} elseif ( $this->in_default_dir( $filtered_src ) ) {
$this->print_code .= $this->print_script_data( $handle, false );
$this->print_code .= $this->print_extra_script( $handle, false );
$this->concat .= "$handle,";
$this->concat_version .= "$handle$ver";
Expand All @@ -398,6 +399,7 @@ public function do_item( $handle, $group = false ) {
}
}

$this->print_script_data( $handle );
$this->print_extra_script( $handle );

// A single item may alias a set of items, by having dependencies, but no source.
Expand Down Expand Up @@ -1235,6 +1237,128 @@ public function reset() {
$this->ext_handles = '';
}

/**
* Prints the data for a registered script as a JSON script tag.
*
* The data is provided via the {@see 'script_data_{$handle}'} filter and printed
* as a `<script type="application/json">` tag. This is a non-blocking alternative
* to passing data via {@see wp_localize_script()} or {@see wp_add_inline_script()},
* as the browser does not evaluate the tag's content as JavaScript.
*
* The consuming script can read the data on the client with a pattern like:
*
* const dataContainer = document.getElementById( 'my-handle-js-data' );
* let data = {};
* if ( dataContainer ) {
* try {
* data = JSON.parse( dataContainer.textContent );
* } catch {}
* }
*
* @since x.x.x
*
* @param string $handle The script's registered handle.
* @param bool $display Optional. Whether to print the script tag (true) or return it (false).
* Default true.
* @return string|bool|void The script data tag when $display is false, true if printed,
* or void if no data.
*/
public function print_script_data( $handle, $display = true ) {
/**
* Filters the data associated with a registered script.
*
* Scripts may require initialization data that is essential to have immediately
* available on page load. These are suitable use cases for this data.
*
* The dynamic portion of the hook name, `$handle`, refers to the script handle
* that the data is associated with.
*
* If the filter returns no data (an empty array), nothing will be printed.
*
* The data for a given script handle, if provided, will be JSON serialized in a
* script tag with a `type` of `application/json` and an ID of the form
* `{$handle}-js-data`.
*
* Example usage:
*
* add_filter(
* 'script_data_my-handle',
* function ( array $data ): array {
* $data['key'] = 'value';
* return $data;
* }
* );
*
* @since x.x.x
*
* @param array $data The data associated with the script handle.
*/
$data = apply_filters( "script_data_{$handle}", array() );

if ( ! is_array( $data ) ) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: 1: The filter name, 2: The handle name. */
__( 'The %1$s filter must return an array. Non-array value returned for handle "%2$s".' ),
"<code>script_data_{$handle}</code>",
$handle
),
'x.x.x'
);
return;
}

if ( array() === $data ) {
return;
}

/*
* This data will be printed as JSON inside a script tag like this:
* <script type="application/json"></script>
*
* A script tag must be closed by a sequence beginning with `</`. It's impossible to
* close a script tag without using `<`. We ensure that `<` is escaped and `/` can
* remain unescaped, so `</script>` will be printed as `\u003C/script>`.
*
* - JSON_HEX_TAG: All < and > are converted to \u003C and \u003E.
* - JSON_UNESCAPED_SLASHES: Don't escape /.
*
* If the page will use UTF-8 encoding, it's safe to print unescaped unicode:
*
* - JSON_UNESCAPED_UNICODE: Encode multibyte Unicode characters literally (instead of as `\uXXXX`).
* - JSON_UNESCAPED_LINE_TERMINATORS: The line terminators are kept unescaped when
* JSON_UNESCAPED_UNICODE is supplied. Available as of PHP 7.1.0.
*/
$json_encode_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_LINE_TERMINATORS;
if ( ! is_utf8_charset() ) {
$json_encode_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES;
}

$output = wp_get_inline_script_tag(
(string) wp_json_encode(
$data,
$json_encode_flags
),
array(
'type' => 'application/json',
'id' => "{$handle}-js-data",
)
);

if ( ! $display ) {
return $output;
}

if ( $this->do_concat ) {
$this->print_html .= $output;
} else {
echo $output;
}

return true;
}

/**
* Gets a script-specific dependency warning message.
*
Expand Down
47 changes: 47 additions & 0 deletions src/wp-includes/functions.wp-scripts.php
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,53 @@ function wp_add_inline_script( $handle, $data, $position = 'after' ) {
return wp_scripts()->add_inline_script( $handle, $data, $position );
}

/**
* Adds data to be passed from PHP to a registered script via a JSON script tag.
*
* The data is printed as a `<script type="application/json" id="{$handle}-js-data">` tag
* immediately before the script tag for the given handle. Because the tag type is
* `application/json`, the browser does not execute its contents, making this a
* non-blocking alternative to {@see wp_localize_script()} or {@see wp_add_inline_script()}.
*
* Data type fidelity is preserved: integers, booleans, arrays, and nested objects
* are all serialized correctly as JSON, unlike {@see wp_localize_script()} which
* coerces all top-level values to strings.
*
* The consuming script can read the data with a pattern like:
*
* const dataContainer = document.getElementById( 'my-handle-js-data' );
* let data = {};
* if ( dataContainer ) {
* try {
* data = JSON.parse( dataContainer.textContent );
* } catch {}
* }
*
* Internally, this function adds a filter on `script_data_{$handle}` to merge
* `$data` into the script's data array. Multiple calls for the same handle are
* merged together.
*
* @since x.x.x
*
* @param string $handle Name of the script to attach data to. Must be lowercase.
* @param array $data Associative array of data to pass to the script.
* @return bool True on success, false if the script is not registered.
*/
function wp_add_script_data( $handle, array $data ) {
if ( ! wp_script_is( $handle, 'registered' ) ) {
return false;
}

add_filter(
"script_data_{$handle}",
static function ( array $existing ) use ( $data ): array {
return array_merge( $existing, $data );
}
);

return true;
}

/**
* Registers a new script.
*
Expand Down
147 changes: 147 additions & 0 deletions tests/phpunit/tests/dependencies/wpAddScriptData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?php
/**
* Tests for wp_add_script_data() and WP_Scripts::print_script_data().
*
* @group dependencies
* @group scripts
* @covers ::wp_add_script_data
* @covers WP_Scripts::print_script_data
*/
class Tests_Dependencies_WpAddScriptData extends WP_UnitTestCase {

/**
* @var WP_Scripts|null
*/
protected $old_wp_scripts;

public function set_up() {
parent::set_up();
$this->old_wp_scripts = $GLOBALS['wp_scripts'] ?? null;
remove_action( 'wp_default_scripts', 'wp_default_scripts' );
remove_action( 'wp_default_scripts', 'wp_default_packages' );
$GLOBALS['wp_scripts'] = new WP_Scripts();
$GLOBALS['wp_scripts']->default_version = get_bloginfo( 'version' );
}

public function tear_down() {
$GLOBALS['wp_scripts'] = $this->old_wp_scripts;
add_action( 'wp_default_scripts', 'wp_default_scripts' );
parent::tear_down();
}

/**
* Returns the output of printing a single script handle.
*
* @param string $handle Script handle to print.
* @return string Captured output.
*/
private function get_print_output( $handle ) {
ob_start();
wp_print_scripts( $handle );
return ob_get_clean();
}

/**
* @ticket 58873
*/
public function test_wp_add_script_data_returns_false_for_unregistered_handle() {
$this->assertFalse( wp_add_script_data( 'nonexistent-handle', array( 'key' => 'value' ) ) );
}

/**
* @ticket 58873
*/
public function test_print_script_data_outputs_json_tag_before_script_and_preserves_types() {
wp_enqueue_script( 'test-handle', '/test.js' );
wp_add_script_data(
'test-handle',
array(
'str' => 'value',
'count' => 42,
'active' => true,
'config' => array( 'url' => 'https://example.com' ),
)
);

$output = $this->get_print_output( 'test-handle' );

// Correct tag format and ID.
$this->assertStringContainsString( '<script id="test-handle-js-data" type="application/json">', $output );
// Type fidelity.
$this->assertStringContainsString( '"count":42', $output );
$this->assertStringContainsString( '"active":true', $output );
$this->assertStringContainsString( '"url":"https://example.com"', $output );
// Printed before the script tag.
$this->assertLessThan(
strpos( $output, 'id="test-handle-js"' ),
strpos( $output, 'test-handle-js-data' ),
'JSON data tag must appear before the script tag.'
);
}

/**
* @ticket 58873
*/
public function test_print_script_data_not_printed_when_no_data() {
wp_enqueue_script( 'test-handle', '/test.js' );

$output = $this->get_print_output( 'test-handle' );

$this->assertStringNotContainsString( 'test-handle-js-data', $output );
}

/**
* @ticket 58873
*/
public function test_multiple_calls_are_merged_and_filter_works_directly() {
wp_enqueue_script( 'test-handle', '/test.js' );
wp_add_script_data( 'test-handle', array( 'first' => 'one' ) );
wp_add_script_data( 'test-handle', array( 'second' => 'two' ) );

add_filter(
'script_data_test-handle',
static function ( array $data ): array {
$data['from_filter'] = 'yes';
return $data;
}
);

$output = $this->get_print_output( 'test-handle' );

$this->assertStringContainsString( '"first":"one"', $output );
$this->assertStringContainsString( '"second":"two"', $output );
$this->assertStringContainsString( '"from_filter":"yes"', $output );
}

/**
* @ticket 58873
*/
public function test_print_script_data_escapes_html_tags_in_json() {
wp_enqueue_script( 'test-handle', '/test.js' );
wp_add_script_data( 'test-handle', array( 'html' => '<script>alert(1)</script>' ) );

$output = $this->get_print_output( 'test-handle' );

$this->assertStringNotContainsString( '<script>alert(1)</script>', $output );
$this->assertStringContainsString( '\u003C', $output );
}

/**
* @ticket 58873
* @expectedIncorrectUsage WP_Scripts::print_script_data
*/
public function test_print_script_data_doing_it_wrong_for_non_array_filter_return() {
wp_enqueue_script( 'test-handle', '/test.js' );

add_filter(
'script_data_test-handle',
static function () {
return 'not an array';
}
);

$output = $this->get_print_output( 'test-handle' );

$this->assertStringNotContainsString( 'test-handle-js-data', $output );
}
}
Loading