diff --git a/src/wp-includes/class-wp-textdomain-registry.php b/src/wp-includes/class-wp-textdomain-registry.php index 403fff9813601..d06d87c4db068 100644 --- a/src/wp-includes/class-wp-textdomain-registry.php +++ b/src/wp-includes/class-wp-textdomain-registry.php @@ -32,6 +32,18 @@ class WP_Textdomain_Registry { */ protected $current = array(); + /** + * List of domains and their custom language directory paths. + * + * @see load_plugin_textdomain() + * @see load_theme_textdomain() + * + * @since 6.1.0 + * + * @var array + */ + protected $custom_paths = array(); + /** * Holds a cached list of available .mo files to improve performance. * @@ -42,7 +54,7 @@ class WP_Textdomain_Registry { protected $cached_mo_files; /** - * Returns the MO file path for a specific domain and locale. + * Returns the languages directory path for a specific domain and locale. * * @since 6.1.0 * @@ -62,33 +74,21 @@ public function get( $domain, $locale ) { /** * Determines whether any MO file paths are available for the domain. * + * This is the case if a path has been set for the current locale, + * or if there is no information stored yet, in which case + * {@see _load_textdomain_just_in_time()} will fetch the information first. + * * @since 6.1.0 * * @param string $domain Text domain. * @return bool Whether any MO file paths are available for the domain. */ public function has( $domain ) { - return ! empty( $this->all[ $domain ] ); - } - - /** - * Returns the current (most recent) MO file path for a specific domain. - * - * @since 6.1.0 - * - * @param string $domain Text domain. - * @return string|false Current MO file path or false if there is none available. - */ - public function get_current( $domain ) { - if ( isset( $this->current[ $domain ] ) ) { - return $this->current[ $domain ]; - } - - return false; + return ! empty( $this->current[ $domain ] ) || empty( $this->all[ $domain ] ); } /** - * Sets the MO file path for a specific domain and locale. + * Sets the language directory path for a specific domain and locale. * * Also sets the 'current' property for direct access * to the path for the current (most recent) locale. @@ -105,81 +105,84 @@ public function set( $domain, $locale, $path ) { } /** - * Resets the registry state. + * Sets the custom path to the plugin's/theme's languages directory. * - * @since 6.1.0 + * Used by {@see load_plugin_textdomain()} and {@see load_theme_textdomain()}. + * + * @param string $domain Text domain. + * @param string $path Language directory path. */ - public function reset() { - $this->cached_mo_files = null; - $this->all = array(); - $this->current = array(); + public function set_custom_path( $domain, $path ) { + $this->custom_paths[ $domain ] = untrailingslashit( $path ); } /** - * Gets the path to a translation file in the languages directory for the current locale. + * Gets the path to the language directory for the current locale. + * + * Checks the plugins and themes language directories as well as any + * custom directory set via {@see load_plugin_textdomain()} or {@see load_theme_textdomain()}. * * @since 6.1.0 * + * @see _get_path_to_translation_from_lang_dir() + * * @param string $domain Text domain. * @param string $locale Locale. - * @return string|false MO file path or false if there is none available. + * @return string|false Language directory path or false if there is none available. */ private function get_path_from_lang_dir( $domain, $locale ) { - if ( null === $this->cached_mo_files ) { - $this->set_cached_mo_files(); + $locations = array( + WP_LANG_DIR . '/plugins', + WP_LANG_DIR . '/themes', + ); + + if ( isset( $this->custom_paths[ $domain ] ) ) { + $locations[] = $this->custom_paths[ $domain ]; } - $mofile = "{$domain}-{$locale}.mo"; + $mofile = "$domain-$locale.mo"; + + foreach ( $locations as $location ) { + if ( ! isset( $this->cached_mo_files[ $location ] ) ) { + $this->set_cached_mo_files( $location ); + } - $path = WP_LANG_DIR . '/plugins/' . $mofile; + $path = $location . '/' . $mofile; - if ( in_array( $path, $this->cached_mo_files, true ) ) { - $path = WP_LANG_DIR . '/plugins/'; - $this->set( $domain, $locale, $path ); + if ( in_array( $path, $this->cached_mo_files[ $location ], true ) ) { + $this->set( $domain, $locale, $location ); - return $path; + return trailingslashit( $location ); + } } - $path = WP_LANG_DIR . '/themes/' . $mofile; - if ( in_array( $path, $this->cached_mo_files, true ) ) { - $path = WP_LANG_DIR . '/themes/'; + // If no path is found for the given locale and a custom path has been set + // using load_plugin_textdomain/load_theme_textdomain, use that one. + if ( 'en_US' !== $locale && isset( $this->custom_paths[ $domain ] ) ) { + $path = trailingslashit( $this->custom_paths[ $domain ] ); $this->set( $domain, $locale, $path ); - return $path; } - // If no path is found for the given locale, check if an entry for the default - // en_US locale exists. This is the case when e.g. using load_plugin_textdomain - // with a custom path. - if ( 'en_US' !== $locale && isset( $this->all[ $domain ]['en_US'] ) ) { - $this->set( $domain, $locale, $this->all[ $domain ]['en_US'] ); - return $this->all[ $domain ]['en_US']; - } - $this->set( $domain, $locale, false ); return false; } /** - * Reads and caches all available MO files from the plugins and themes language directories. + * Reads and caches all available MO files from a given directory. * * @since 6.1.0 + * + * @param string $path Language directory path. */ - protected function set_cached_mo_files() { - $this->cached_mo_files = array(); - - $locations = array( - WP_LANG_DIR . '/plugins', - WP_LANG_DIR . '/themes', - ); + private function set_cached_mo_files( $path ) { + $this->cached_mo_files[ $path ] = array(); - foreach ( $locations as $location ) { - $mo_files = glob( $location . '/*.mo' ); + $mo_files = glob( $path . '/*.mo' ); - if ( $mo_files ) { - $this->cached_mo_files = array_merge( $this->cached_mo_files, $mo_files ); - } + if ( $mo_files ) { + $this->cached_mo_files[ $path ] = $mo_files; } } } diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index ea4ca9bcd366a..c5148a02e8c67 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -934,7 +934,7 @@ function load_plugin_textdomain( $domain, $deprecated = false, $plugin_rel_path $path = WP_PLUGIN_DIR; } - $wp_textdomain_registry->set( $domain, $locale, $path ); + $wp_textdomain_registry->set_custom_path( $domain, $path ); return load_textdomain( $domain, $path . '/' . $mofile, $locale ); } @@ -968,7 +968,7 @@ function load_muplugin_textdomain( $domain, $mu_plugin_rel_path = '' ) { $path = WPMU_PLUGIN_DIR . '/' . ltrim( $mu_plugin_rel_path, '/' ); - $wp_textdomain_registry->set( $domain, $locale, $path ); + $wp_textdomain_registry->set_custom_path( $domain, $path ); return load_textdomain( $domain, $path . '/' . $mofile, $locale ); } @@ -1016,7 +1016,7 @@ function load_theme_textdomain( $domain, $path = false ) { $path = get_template_directory(); } - $wp_textdomain_registry->set( $domain, $locale, $path ); + $wp_textdomain_registry->set_custom_path( $domain, $path ); return load_textdomain( $domain, $path . '/' . $locale . '.mo', $locale ); } @@ -1265,7 +1265,7 @@ function _load_textdomain_just_in_time( $domain ) { return false; } - if ( $wp_textdomain_registry->has( $domain ) && ! $wp_textdomain_registry->get_current( $domain ) ) { + if ( ! $wp_textdomain_registry->has( $domain ) ) { return false; } diff --git a/tests/phpunit/tests/l10n/loadTextdomain.php b/tests/phpunit/tests/l10n/loadTextdomain.php index 882cb9535f242..26e567d6638ff 100644 --- a/tests/phpunit/tests/l10n/loadTextdomain.php +++ b/tests/phpunit/tests/l10n/loadTextdomain.php @@ -28,7 +28,7 @@ public function set_up() { /** @var WP_Textdomain_Registry $wp_textdomain_registry */ global $wp_textdomain_registry; - $wp_textdomain_registry->reset(); + $wp_textdomain_registry = new WP_Textdomain_Registry(); } public function tear_down() { @@ -36,7 +36,7 @@ public function tear_down() { /** @var WP_Textdomain_Registry $wp_textdomain_registry */ global $wp_textdomain_registry; - $wp_textdomain_registry->reset(); + $wp_textdomain_registry = new WP_Textdomain_Registry(); parent::tear_down(); } diff --git a/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php b/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php index 2d3c480a5e412..4f29c0c586bc1 100644 --- a/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php +++ b/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php @@ -8,7 +8,6 @@ class Tests_L10n_LoadTextdomainJustInTime extends WP_UnitTestCase { protected $orig_theme_dir; protected $theme_root; protected static $user_id; - private $locale_count; public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { self::$user_id = $factory->user->create( @@ -24,7 +23,6 @@ public function set_up() { $this->theme_root = DIR_TESTDATA . '/themedir1'; $this->orig_theme_dir = $GLOBALS['wp_theme_directories']; - $this->locale_count = 0; // /themes is necessary as theme.php functions assume /themes is the root if there is only one root. $GLOBALS['wp_theme_directories'] = array( WP_CONTENT_DIR . '/themes', $this->theme_root ); @@ -37,7 +35,7 @@ public function set_up() { /** @var WP_Textdomain_Registry $wp_textdomain_registry */ global $wp_textdomain_registry; - $wp_textdomain_registry->reset(); + $wp_textdomain_registry = new WP_Textdomain_Registry(); } public function tear_down() { @@ -48,7 +46,7 @@ public function tear_down() { /** @var WP_Textdomain_Registry $wp_textdomain_registry */ global $wp_textdomain_registry; - $wp_textdomain_registry->reset(); + $wp_textdomain_registry = new WP_Textdomain_Registry(); parent::tear_down(); } @@ -262,7 +260,8 @@ public function test_theme_translation_with_user_locale() { public function test_get_locale_is_called_only_once_per_textdomain() { $textdomain = 'foo-bar-baz'; - add_filter( 'locale', array( $this, '_filter_locale_count' ) ); + $filter = new MockAction(); + add_filter( 'locale', array( $filter, 'filter' ) ); __( 'Foo', $textdomain ); __( 'Bar', $textdomain ); @@ -270,15 +269,31 @@ public function test_get_locale_is_called_only_once_per_textdomain() { __( 'Foo Bar', $textdomain ); __( 'Foo Bar Baz', $textdomain ); - remove_filter( 'locale', array( $this, '_filter_locale_count' ) ); - $this->assertFalse( is_textdomain_loaded( $textdomain ) ); - $this->assertSame( 1, $this->locale_count ); + $this->assertSame( 1, $filter->get_call_count() ); } - public function _filter_locale_count( $locale ) { - ++$this->locale_count; + /** + * @ticket 37997 + * @ticket 39210 + * + * @covers ::_load_textdomain_just_in_time + */ + public function test_get_locale_is_called_only_once_per_textdomain_with_custom_lang_dir() { + load_plugin_textdomain( 'custom-internationalized-plugin', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' ); + + $textdomain = 'custom-internationalized-plugin'; - return $locale; + $filter = new MockAction(); + add_filter( 'locale', array( $filter, 'filter' ) ); + + __( 'Foo', $textdomain ); + __( 'Bar', $textdomain ); + __( 'Baz', $textdomain ); + __( 'Foo Bar', $textdomain ); + __( 'Foo Bar Baz', $textdomain ); + + $this->assertFalse( is_textdomain_loaded( $textdomain ) ); + $this->assertSame( 1, $filter->get_call_count() ); } } diff --git a/tests/phpunit/tests/l10n/wpLocaleSwitcher.php b/tests/phpunit/tests/l10n/wpLocaleSwitcher.php index c1791d8c107df..3fbea448c7ca5 100644 --- a/tests/phpunit/tests/l10n/wpLocaleSwitcher.php +++ b/tests/phpunit/tests/l10n/wpLocaleSwitcher.php @@ -27,7 +27,7 @@ public function set_up() { /** @var WP_Textdomain_Registry $wp_textdomain_registry */ global $wp_textdomain_registry; - $wp_textdomain_registry->reset(); + $wp_textdomain_registry = new WP_Textdomain_Registry(); } public function tear_down() { @@ -36,7 +36,7 @@ public function tear_down() { /** @var WP_Textdomain_Registry $wp_textdomain_registry */ global $wp_textdomain_registry; - $wp_textdomain_registry->reset(); + $wp_textdomain_registry = new WP_Textdomain_Registry(); parent::tear_down(); } @@ -478,11 +478,12 @@ public function test_switch_reloads_plugin_translations_outside_wp_lang_dir() { require_once DIR_TESTDATA . '/plugins/custom-internationalized-plugin/custom-internationalized-plugin.php'; - $registry_value = $wp_textdomain_registry->get( 'custom-internationalized-plugin', determine_locale() ); - $actual = custom_i18n_plugin_test(); switch_to_locale( 'es_ES' ); + + $registry_value = $wp_textdomain_registry->get( 'custom-internationalized-plugin', determine_locale() ); + switch_to_locale( 'de_DE' ); $actual_de_de = custom_i18n_plugin_test(); @@ -517,11 +518,12 @@ public function test_switch_reloads_theme_translations_outside_wp_lang_dir() { require_once get_stylesheet_directory() . '/functions.php'; - $registry_value = $wp_textdomain_registry->get( 'custom-internationalized-theme', determine_locale() ); - $actual = custom_i18n_theme_test(); switch_to_locale( 'es_ES' ); + + $registry_value = $wp_textdomain_registry->get( 'custom-internationalized-theme', determine_locale() ); + switch_to_locale( 'de_DE' ); $actual_de_de = custom_i18n_theme_test(); diff --git a/tests/phpunit/tests/l10n/wpTextdomainRegistry.php b/tests/phpunit/tests/l10n/wpTextdomainRegistry.php new file mode 100644 index 0000000000000..4186d8555aeb5 --- /dev/null +++ b/tests/phpunit/tests/l10n/wpTextdomainRegistry.php @@ -0,0 +1,150 @@ +instance = new WP_Textdomain_Registry(); + } + + /** + * @covers ::has + * @covers ::get + * @covers ::set_custom_path + */ + public function test_set_custom_path() { + $reflection = new ReflectionClass( $this->instance ); + $reflection_property = $reflection->getProperty( 'cached_mo_files' ); + $reflection_property->setAccessible( true ); + + $this->assertNull( + $reflection_property->getValue( $this->instance ), + 'Cache not empty by default' + ); + + $this->instance->set_custom_path( 'foo', WP_LANG_DIR . '/bar' ); + + $this->assertTrue( + $this->instance->has( 'foo' ), + 'Incorrect availability status for textdomain with custom path' + ); + $this->assertFalse( + $this->instance->get( 'foo', 'en_US' ), + 'Should not return custom path for textdomain and en_US locale' + ); + $this->assertSame( + WP_LANG_DIR . '/bar/', + $this->instance->get( 'foo', 'de_DE' ), + 'Custom path for textdomain not returned' + ); + $this->assertArrayHasKey( + WP_LANG_DIR . '/bar', + $reflection_property->getValue( $this->instance ), + 'Custom path missing from cache' + ); + } + + /** + * @covers ::get + * @dataProvider data_domains_locales + */ + public function test_get( $domain, $locale, $expected ) { + $reflection = new ReflectionClass( $this->instance ); + $reflection_property = $reflection->getProperty( 'cached_mo_files' ); + $reflection_property->setAccessible( true ); + + $actual = $this->instance->get( $domain, $locale ); + $this->assertSame( + $expected, + $actual, + 'Expected languages directory path not matching actual one' + ); + + $this->assertArrayHasKey( + WP_LANG_DIR . '/plugins', + $reflection_property->getValue( $this->instance ), + 'Default plugins path missing from cache' + ); + } + + /** + * @covers ::get_path_from_lang_dir + */ + public function test_get_does_not_check_themes_directory_for_plugin() { + $reflection = new ReflectionClass( $this->instance ); + $reflection_property = $reflection->getProperty( 'cached_mo_files' ); + $reflection_property->setAccessible( true ); + + $this->instance->get( 'internationalized-plugin', 'de_DE' ); + + $this->assertArrayHasKey( + WP_LANG_DIR . '/plugins', + $reflection_property->getValue( $this->instance ), + 'Default plugins path missing from cache' + ); + $this->assertArrayNotHasKey( + WP_LANG_DIR . '/themes', + $reflection_property->getValue( $this->instance ), + 'Default themes path should not be in cache' + ); + } + + /** + * @covers ::set + * @covers ::get + */ + public function test_set_populates_cache() { + $this->instance->set( 'foo-plugin', 'de_DE', '/foo/bar' ); + + $this->assertSame( + '/foo/bar/', + $this->instance->get( 'foo-plugin', 'de_DE' ) + ); + } + + public function data_domains_locales() { + return array( + 'Non-existent plugin' => array( + 'unknown-plugin', + 'en_US', + false, + ), + 'Non-existent plugin with de_DE' => array( + 'unknown-plugin', + 'de_DE', + false, + ), + 'Available de_DE translations' => array( + 'internationalized-plugin', + 'de_DE', + WP_LANG_DIR . '/plugins/', + ), + 'Available es_ES translations' => array( + 'internationalized-plugin', + 'es_ES', + WP_LANG_DIR . '/plugins/', + ), + 'Unavailable fr_FR translations' => array( + 'internationalized-plugin', + 'fr_FR', + false, + ), + 'Unavailable en_US translations' => array( + 'internationalized-plugin', + 'en_US', + false, + ), + ); + } +}