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. *