diff --git a/src/wp-includes/class-wp-dependency.php b/src/wp-includes/class-wp-dependency.php
index d14928f60b..cea4de5507 100644
--- a/src/wp-includes/class-wp-dependency.php
+++ b/src/wp-includes/class-wp-dependency.php
@@ -67,6 +67,22 @@ class _WP_Dependency {
*/
public $extra = array();
+ /**
+ * Translation textdomain set for this dependency.
+ *
+ * @since 5.0.0
+ * @var string
+ */
+ public $textdomain;
+
+ /**
+ * Translation path set for this dependency.
+ *
+ * @since 5.0.0
+ * @var string
+ */
+ public $translations_path;
+
/**
* Setup dependencies.
*
@@ -96,4 +112,12 @@ class _WP_Dependency {
return true;
}
+ public function set_translations( $domain, $path = null ) {
+ if ( ! is_string( $domain ) ) {
+ return false;
+ }
+ $this->textdomain = $domain;
+ $this->translations_path = $path;
+ return true;
+ }
}
diff --git a/src/wp-includes/class.wp-scripts.php b/src/wp-includes/class.wp-scripts.php
index 6f8e4f9a8c..42374815d0 100644
--- a/src/wp-includes/class.wp-scripts.php
+++ b/src/wp-includes/class.wp-scripts.php
@@ -330,6 +330,11 @@ class WP_Scripts extends WP_Dependencies {
return true;
}
+ $translations = $this->print_translations( $handle, false );
+ if ( $translations ) {
+ $translations = sprintf( "\n", $translations );
+ }
+
if ( ! preg_match( '|^(https?:)?//|', $src ) && ! ( $this->content_url && 0 === strpos( $src, $this->content_url ) ) ) {
$src = $this->base_url . $src;
}
@@ -345,7 +350,7 @@ class WP_Scripts extends WP_Dependencies {
return true;
}
- $tag = "{$cond_before}{$before_handle}\n{$after_handle}{$cond_after}";
+ $tag = "{$translations}{$cond_before}{$before_handle}\n{$after_handle}{$cond_after}";
/**
* Filters the HTML script tag of an enqueued script.
@@ -490,6 +495,67 @@ class WP_Scripts extends WP_Dependencies {
return parent::set_group( $handle, $recursion, $grp );
}
+ /**
+ * Sets a translation textdomain.
+ *
+ * @since 5.0.0
+ *
+ * @param string $handle Name of the script to register a translation domain to.
+ * @param string $domain The textdomain.
+ * @param string $path Optional. The full file path to the directory containing translation files.
+ *
+ * @return bool True if the textdomain was registered, false if not.
+ */
+ public function set_translations( $handle, $domain, $path = null ) {
+ if ( ! isset( $this->registered[ $handle ] ) ) {
+ return false;
+ }
+
+ /** @var \_WP_Dependency $obj */
+ $obj = $this->registered[ $handle ];
+
+ if ( ! in_array( 'wp-i18n', $obj->deps, true ) ) {
+ $obj->deps[] = 'wp-i18n';
+ }
+ return $obj->set_translations( $domain, $path );
+ }
+
+ /**
+ * Prints translations set for a specific handle.
+ *
+ * @since 5.0.0
+ *
+ * @param string $handle Name of the script to add the inline script to. Must be lowercase.
+ * @param bool $echo Optional. Whether to echo the script instead of just returning it.
+ * Default true.
+ * @return string|false Script on success, false otherwise.
+ */
+ public function print_translations( $handle, $echo = true ) {
+ if ( ! isset( $this->registered[ $handle ] ) || empty( $this->registered[ $handle ]->textdomain ) ) {
+ return false;
+ }
+
+ $domain = $this->registered[ $handle ]->textdomain;
+ $path = $this->registered[ $handle ]->translations_path;
+
+ $json_translations = load_script_textdomain( $handle, $domain, $path );
+
+ if ( ! $json_translations ) {
+ // Register empty locale data object to ensure the domain still exists.
+ $json_translations = '{ "locale_data": { "messages": { "": {} } } }';
+ }
+
+ $output = '(function( translations ){' .
+ 'wp.i18n.setLocaleData( translations.locale_data, "' . $domain . '" );' .
+ '})(' . $json_translations . ');';
+
+ if ( $echo ) {
+ printf( "\n", $output );
+ }
+
+ return $output;
+ }
+
/**
* Determines script dependencies.
*
diff --git a/src/wp-includes/functions.wp-scripts.php b/src/wp-includes/functions.wp-scripts.php
index 3fdf33974c..af86eddb96 100644
--- a/src/wp-includes/functions.wp-scripts.php
+++ b/src/wp-includes/functions.wp-scripts.php
@@ -200,6 +200,32 @@ function wp_localize_script( $handle, $object_name, $l10n ) {
return $wp_scripts->localize( $handle, $object_name, $l10n );
}
+/**
+ * Sets translated strings for a script.
+ *
+ * Works only if the script has already been added.
+ *
+ * @see WP_Scripts::set_translations()
+ * @global WP_Scripts $wp_scripts The WP_Scripts object for printing scripts.
+ *
+ * @since 5.0.0
+ *
+ * @param string $handle Script handle the textdomain will be attached to.
+ * @param string $domain The textdomain.
+ * @param string $path Optional. The full file path to the directory containing translation files.
+ *
+ * @return bool True if the textdomain was successfully localized, false otherwise.
+ */
+function wp_set_script_translations( $handle, $domain, $path = null ) {
+ global $wp_scripts;
+ if ( ! ( $wp_scripts instanceof WP_Scripts ) ) {
+ _wp_scripts_maybe_doing_it_wrong( __FUNCTION__ );
+ return false;
+ }
+
+ return $wp_scripts->set_translations( $handle, $domain, $path );
+}
+
/**
* Remove a registered script.
*
diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php
index 312ae31f90..3705f46f23 100644
--- a/src/wp-includes/l10n.php
+++ b/src/wp-includes/l10n.php
@@ -893,6 +893,91 @@ function load_child_theme_textdomain( $domain, $path = false ) {
return load_theme_textdomain( $domain, $path );
}
+/**
+ * Load the script translated strings.
+ *
+ * @see WP_Scripts::set_translations()
+ * @link https://core.trac.wordpress.org/ticket/45103
+ * @global WP_Scripts $wp_scripts The WP_Scripts object for printing scripts.
+ *
+ * @since 5.0.0
+ *
+ * @param string $handle Name of the script to register a translation domain to.
+ * @param string $domain The textdomain.
+ * @param string $path Optional. The full file path to the directory containing translation files.
+ *
+ * @return false|string False if the script textdomain could not be loaded, the translated strings
+ * in JSON encoding otherwise.
+ */
+function load_script_textdomain( $handle, $domain, $path = null ) {
+ global $wp_scripts;
+
+ $path = untrailingslashit( $path );
+ $locale = is_admin() ? get_locale() : get_user_locale();
+
+ // If a path was given and the handle file exists simply return it.
+ $file_base = $domain === 'default' ? $locale : $domain . '-' . $locale;
+ $handle_filename = $file_base . '-' . $handle . '.json';
+ if ( $path && file_exists( $path . '/' . $handle_filename ) ) {
+ return file_get_contents( $path . '/' . $handle_filename );
+ }
+
+ $obj = $wp_scripts->registered[ $handle ];
+
+ /** This filter is documented in wp-includes/class.wp-scripts.php */
+ $src = esc_url( apply_filters( 'script_loader_src', $obj->src, $handle ) );
+
+ $relative = false;
+ $languages_path = WP_LANG_DIR;
+
+ $src_url = wp_parse_url( $src );
+ $content_url = wp_parse_url( content_url() );
+ $site_url = wp_parse_url( site_url() );
+
+ // If the host is the same or it's a relative URL.
+ if (
+ strpos( $src_url['path'], $content_url['path'] ) === 0 &&
+ ( ! isset( $src_url['host'] ) || $src_url['host'] !== $content_url['host'] )
+ ) {
+ // Make the src relative the specific plugin or theme.
+ $relative = trim( substr( $src, strlen( $content_url['path'] ) ), '/' );
+ $relative = explode( '/', $relative );
+
+ $languages_path = WP_LANG_DIR . '/' . $relative[0];
+
+ $relative = array_slice( $relative, 2 );
+ $relative = implode( '/', $relative );
+ } elseif ( ! isset( $src_url['host'] ) || $src_url['host'] !== $site_url['host'] ) {
+ if ( ! isset( $site_url['path'] ) ) {
+ $relative = trim( $src_url['path'], '/' );
+ } elseif ( ( strpos( $src_url['path'], $site_url['path'] ) === 0 ) ) {
+ // Make the src relative to the WP root.
+ $relative = substr( $src, strlen( $site_url['path'] ) );
+ $relative = trim( $relative, '/' );
+ }
+ }
+
+ // If the source is not from WP.
+ if ( false === $relative ) {
+ return false;
+ }
+
+ // Translations are always based on the unminified filename.
+ if ( substr( $relative, -7 ) === '.min.js' ) {
+ $relative = substr( $relative, 0, -7 ) . '.js';
+ }
+
+ $md5_filename = $file_base . '-' . md5( $relative ) . '.json';
+ if ( $path && file_exists( $path . '/' . $md5_filename ) ) {
+ return file_get_contents( $path . '/' . $md5_filename );
+ }
+ if ( file_exists( $languages_path . '/' . $md5_filename ) ) {
+ return file_get_contents( $languages_path . '/' . $md5_filename );
+ }
+
+ return false;
+}
+
/**
* Loads plugin and theme textdomains just-in-time.
*
diff --git a/tests/phpunit/data/languages/admin-en_US-script-handle.json b/tests/phpunit/data/languages/admin-en_US-script-handle.json
new file mode 100644
index 0000000000..de76538ea5
--- /dev/null
+++ b/tests/phpunit/data/languages/admin-en_US-script-handle.json
@@ -0,0 +1,17 @@
+{
+ "translation-revision-data": "+0000",
+ "generator": "GlotPress/2.3.0-alpha",
+ "domain": "messages",
+ "locale_data": {
+ "messages": {
+ "": {
+ "domain": "messages",
+ "plural-forms": "n != 1",
+ "lang": "en-gb"
+ },
+ "This file is a translation for script-handle.": [
+ "This file is a translation for script-handle."
+ ]
+ }
+ }
+}
diff --git a/tests/phpunit/data/languages/en_US-813e104eb47e13dd4cc5af844c618754.json b/tests/phpunit/data/languages/en_US-813e104eb47e13dd4cc5af844c618754.json
new file mode 100644
index 0000000000..367f1390ea
--- /dev/null
+++ b/tests/phpunit/data/languages/en_US-813e104eb47e13dd4cc5af844c618754.json
@@ -0,0 +1,30 @@
+{
+ "translation-revision-data": "+0000",
+ "generator": "GlotPress/2.3.0-alpha",
+ "domain": "messages",
+ "locale_data": {
+ "messages": {
+ "": {
+ "domain": "messages",
+ "plural-forms": "n != 1",
+ "lang": "en-gb"
+ },
+ "This file is too big. Files must be less than %d KB in size.": [
+ "This file is too big. Files must be less than %d KB in size."
+ ],
+ "%d Theme Update": [
+ "%d Theme Update",
+ "%d Theme Updates"
+ ],
+ "password strength\u0004Medium": [
+ "Medium"
+ ],
+ "taxonomy singular name\u0004Category": [
+ "Category"
+ ],
+ "post type general name\u0004Pages": [
+ "Pages"
+ ]
+ }
+ }
+}
diff --git a/tests/phpunit/data/languages/plugins/internationalized-plugin-en_US-2f86cb96a0233e7cb3b6f03ad573be0b.json b/tests/phpunit/data/languages/plugins/internationalized-plugin-en_US-2f86cb96a0233e7cb3b6f03ad573be0b.json
new file mode 100644
index 0000000000..19e2ba24da
--- /dev/null
+++ b/tests/phpunit/data/languages/plugins/internationalized-plugin-en_US-2f86cb96a0233e7cb3b6f03ad573be0b.json
@@ -0,0 +1,17 @@
+{
+ "translation-revision-data": "+0000",
+ "generator": "GlotPress/2.3.0-alpha",
+ "domain": "messages",
+ "locale_data": {
+ "messages": {
+ "": {
+ "domain": "messages",
+ "plural-forms": "n != 1",
+ "lang": "en-gb"
+ },
+ "This is a dummy plugin.": [
+ "This is a dummy plugin."
+ ]
+ }
+ }
+}
diff --git a/tests/phpunit/data/languages/themes/internationalized-theme-en_US-2f86cb96a0233e7cb3b6f03ad573be0b.json b/tests/phpunit/data/languages/themes/internationalized-theme-en_US-2f86cb96a0233e7cb3b6f03ad573be0b.json
new file mode 100644
index 0000000000..280ccc1424
--- /dev/null
+++ b/tests/phpunit/data/languages/themes/internationalized-theme-en_US-2f86cb96a0233e7cb3b6f03ad573be0b.json
@@ -0,0 +1,17 @@
+{
+ "translation-revision-data": "+0000",
+ "generator": "GlotPress/2.3.0-alpha",
+ "domain": "messages",
+ "locale_data": {
+ "messages": {
+ "": {
+ "domain": "messages",
+ "plural-forms": "n != 1",
+ "lang": "en-gb"
+ },
+ "This is a dummy theme.": [
+ "This is a dummy theme."
+ ]
+ }
+ }
+}
diff --git a/tests/phpunit/tests/dependencies/scripts.php b/tests/phpunit/tests/dependencies/scripts.php
index 198d970697..5f03e9acc2 100644
--- a/tests/phpunit/tests/dependencies/scripts.php
+++ b/tests/phpunit/tests/dependencies/scripts.php
@@ -773,6 +773,146 @@ class Tests_Dependencies_Scripts extends WP_UnitTestCase {
$this->assertEquals( $expected, get_echo( 'wp_print_scripts' ) );
}
+ /**
+ * @ticket 45103
+ */
+ public function test_wp_set_script_translations() {
+ wp_register_script( 'wp-i18n', '/wp-includes/js/dist/wp-i18n.js', array(), null );
+ wp_enqueue_script( 'test-example', '/wp-includes/js/script.js', array(), null );
+ wp_set_script_translations( 'test-example', 'default', DIR_TESTDATA . '/languages' );
+
+ $expected = "";
+ $expected .= "\n\n";
+ $expected .= "\n";
+
+ $this->assertEquals( $expected, get_echo( 'wp_print_scripts' ) );
+ }
+
+ /**
+ * @ticket 45103
+ */
+ public function test_wp_set_script_translations_for_plugin() {
+ wp_register_script( 'wp-i18n', '/wp-includes/js/dist/wp-i18n.js', array(), null );
+ wp_enqueue_script( 'plugin-example', '/wp-content/plugins/my-plugin/js/script.js', array(), null );
+ wp_set_script_translations( 'plugin-example', 'internationalized-plugin', DIR_TESTDATA . '/languages/plugins' );
+
+ $expected = "";
+ $expected .= "\n\n";
+ $expected .= "\n";
+
+ $this->assertEquals( $expected, get_echo( 'wp_print_scripts' ) );
+ }
+
+ /**
+ * @ticket 45103
+ */
+ public function test_wp_set_script_translations_for_theme() {
+ wp_register_script( 'wp-i18n', '/wp-includes/js/dist/wp-i18n.js', array(), null );
+ wp_enqueue_script( 'theme-example', '/wp-content/themes/my-theme/js/script.js', array(), null );
+ wp_set_script_translations( 'theme-example', 'internationalized-theme', DIR_TESTDATA . '/languages/themes' );
+
+ $expected = "";
+ $expected .= "\n\n";
+ $expected .= "\n";
+
+ $this->assertEquals( $expected, get_echo( 'wp_print_scripts' ) );
+ }
+
+ /**
+ * @ticket 45103
+ */
+ public function test_wp_set_script_translations_with_handle_file() {
+ wp_register_script( 'wp-i18n', '/wp-includes/js/dist/wp-i18n.js', array(), null );
+ wp_enqueue_script( 'script-handle', '/wp-admin/js/script.js', array(), null );
+ wp_set_script_translations( 'script-handle', 'admin', DIR_TESTDATA . '/languages/' );
+
+ $expected = "";
+ $expected .= "\n\n";
+ $expected .= "\n";
+
+ $this->assertEquals( $expected, get_echo( 'wp_print_scripts' ) );
+ }
+
+ /**
+ * @ticket 45103
+ */
+ public function test_wp_set_script_translations_i18n_dependency() {
+ global $wp_scripts;
+
+ wp_register_script( 'wp-i18n', '/wp-includes/js/dist/wp-i18n.js', array(), null );
+ wp_enqueue_script( 'test-example', '/wp-includes/js/script.js', array(), null );
+ wp_set_script_translations( 'test-example', 'default', DIR_TESTDATA . '/languages/' );
+
+ $script = $wp_scripts->registered['test-example'];
+
+ $this->assertContains( 'wp-i18n', $script->deps );
+ }
+
+ /**
+ * @ticket 45103
+ */
+ public function test_wp_set_script_translations_when_translation_file_does_not_exist() {
+ wp_register_script( 'wp-i18n', '/wp-includes/js/dist/wp-i18n.js', array(), null );
+ wp_enqueue_script( 'test-example', '/wp-admin/js/script.js', array(), null );
+ wp_set_script_translations( 'test-example', 'admin', DIR_TESTDATA . '/languages/' );
+
+ $expected = "";
+ $expected .= "\n\n";
+ $expected .= "\n";
+
+ $this->assertEquals( $expected, get_echo( 'wp_print_scripts' ) );
+ }
+
+ /**
+ * @ticket 45103
+ */
+ public function test_wp_set_script_translations_after_register() {
+ wp_register_script( 'wp-i18n', '/wp-includes/js/dist/wp-i18n.js', array(), null );
+ wp_register_script( 'test-example', '/wp-includes/js/script.js', array(), null );
+ wp_set_script_translations( 'test-example', 'default', DIR_TESTDATA . '/languages' );
+
+ wp_enqueue_script( 'test-example' );
+
+ $expected = "";
+ $expected .= "\n\n";
+ $expected .= "\n";
+
+ $this->assertEquals( $expected, get_echo( 'wp_print_scripts' ) );
+ }
+
+ /**
+ * @ticket 45103
+ */
+ public function test_wp_set_script_translations_dependency() {
+ wp_register_script( 'wp-i18n', '/wp-includes/js/dist/wp-i18n.js', array(), null );
+ wp_register_script( 'test-dependency', '/wp-includes/js/script.js', array(), null );
+ wp_set_script_translations( 'test-dependency', 'default', DIR_TESTDATA . '/languages' );
+
+ wp_enqueue_script( 'test-example', '/wp-includes/js/script2.js', array( 'test-dependency' ), null );
+
+ $expected = "";
+ $expected .= "\n\n";
+ $expected .= "\n";
+ $expected .= "\n";
+
+ $this->assertEquals( $expected, get_echo( 'wp_print_scripts' ) );
+ }
+
/**
* Testing `wp_enqueue_code_editor` with file path.
*