diff --git a/src/wp-includes/class-wp-textdomain-registry.php b/src/wp-includes/class-wp-textdomain-registry.php index c62e751b6e47b..7d924d5abdf1d 100644 --- a/src/wp-includes/class-wp-textdomain-registry.php +++ b/src/wp-includes/class-wp-textdomain-registry.php @@ -63,8 +63,11 @@ class WP_Textdomain_Registry { * Holds a cached list of domains with translations to improve performance. * * @since 6.2.0 + * @since 7.2.0 This property is no longer used. * * @var string[] + * + * @deprecated */ protected $domains_with_translations = array(); @@ -108,10 +111,14 @@ 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. + * if there is no information stored yet, in which case + * {@see _load_textdomain_just_in_time()} will fetch the information first, + * or if a custom path has been registered via {@see load_plugin_textdomain()} + * or {@see load_theme_textdomain()}, which is always worth looking at. * * @since 6.1.0 + * @since 7.2.0 Checks for a registered custom path instead of the + * `$domains_with_translations` property. * * @param string $domain Text domain. * @return bool Whether any MO file paths are available for the domain. @@ -120,7 +127,7 @@ public function has( $domain ) { return ( isset( $this->current[ $domain ] ) || empty( $this->all[ $domain ] ) || - in_array( $domain, $this->domains_with_translations, true ) + isset( $this->custom_paths[ $domain ] ) ); } @@ -321,13 +328,6 @@ private function get_path_from_lang_dir( $domain, $locale ) { $php_path = "$location/$domain-$locale.l10n.php"; foreach ( $files as $file_path ) { - if ( - ! in_array( $domain, $this->domains_with_translations, true ) && - str_starts_with( str_replace( "$location/", '', $file_path ), "$domain-" ) - ) { - $this->domains_with_translations[] = $domain; - } - if ( $file_path === $mo_path || $file_path === $php_path ) { $found_location = rtrim( $location, '/' ) . '/'; break 2; diff --git a/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php b/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php index af8bb8825ac9e..ebf7dfc6da47d 100644 --- a/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php +++ b/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php @@ -372,4 +372,72 @@ public function test_plugin_translation_should_be_translated_when_calling_load_p $this->assertSame( 'Das ist ein Dummy Plugin', $output_after ); $this->assertTrue( $is_textdomain_loaded_after ); } + + /** + * Text domains that merely share a prefix with a translated one must not be + * treated as translated themselves. + * + * "internationalized-plugin-de_DE.mo" exists, but there is no + * "internationalized-de_DE.mo", so the "internationalized" text domain has no + * translations at all. + * + * @ticket 62348 + * + * @covers ::_load_textdomain_just_in_time + * @covers ::is_textdomain_loaded + */ + public function test_text_domain_sharing_a_prefix_with_a_translated_one_is_not_translated() { + add_filter( 'locale', array( $this, 'filter_set_locale_to_german' ) ); + + $actual = __( 'This is a dummy plugin', 'internationalized' ); + + remove_filter( 'locale', array( $this, 'filter_set_locale_to_german' ) ); + + $this->assertFalse( + is_textdomain_loaded( 'internationalized' ), + 'The "internationalized" text domain should not be considered loaded' + ); + $this->assertSame( + 'This is a dummy plugin', + $actual, + 'A text domain sharing a prefix with a translated one should not be translated' + ); + } + + /** + * Calling load_plugin_textdomain() after translations have already been loaded + * for one locale and missed for another must not stop just-in-time loading. + * + * Since 6.7.0 load_plugin_textdomain() only registers a custom path and leaves + * the loading to _load_textdomain_just_in_time(), which bails out early when + * WP_Textdomain_Registry::has() returns false. + * + * @ticket 62348 + * + * @covers ::_load_textdomain_just_in_time + * @covers ::load_plugin_textdomain + */ + public function test_plugin_translations_survive_a_late_load_plugin_textdomain_call() { + require_once DIR_TESTDATA . '/plugins/internationalized-plugin.php'; + + // de_DE translations are found in the WordPress languages directory. + switch_to_locale( 'de_DE' ); + $before = i18n_plugin_test(); + + // A locale without any translations for this plugin is used in between. + switch_to_locale( 'ja_JP' ); + $untranslated = i18n_plugin_test(); + + // Only now does the plugin register its own languages directory. + load_plugin_textdomain( 'internationalized-plugin', false, 'internationalized-plugin/languages' ); + + restore_previous_locale(); + $after = i18n_plugin_test(); + + restore_current_locale(); + + $this->assertSame( 'Das ist ein Dummy Plugin', $before, 'de_DE translations should be loaded up front' ); + $this->assertSame( 'This is a dummy plugin', $untranslated, 'There should be no ja_JP translations' ); + $this->assertSame( 'Das ist ein Dummy Plugin', $after, 'de_DE translations should survive load_plugin_textdomain()' ); + } } diff --git a/tests/phpunit/tests/l10n/wpTextdomainRegistry.php b/tests/phpunit/tests/l10n/wpTextdomainRegistry.php index f5a12ff779dde..1402a86ce0ad4 100644 --- a/tests/phpunit/tests/l10n/wpTextdomainRegistry.php +++ b/tests/phpunit/tests/l10n/wpTextdomainRegistry.php @@ -23,6 +23,7 @@ public function tear_down() { wp_cache_delete( md5( WP_LANG_DIR . '/plugins/' ), 'translation_files' ); wp_cache_delete( md5( WP_LANG_DIR . '/themes/' ), 'translation_files' ); wp_cache_delete( md5( WP_LANG_DIR . '/' ), 'translation_files' ); + wp_cache_delete( md5( WP_PLUGIN_DIR . '/custom-internationalized-plugin/languages/' ), 'translation_files' ); parent::tear_down(); } @@ -150,6 +151,172 @@ public function test_invalidate_mo_files_cache() { $this->assertFalse( wp_cache_get( md5( WP_LANG_DIR . '/' ), 'translation_files' ) ); } + /** + * The registry answers "yes" for a text domain it has never looked up, + * so that _load_textdomain_just_in_time() gets a chance to resolve it. + * + * @ticket 62348 + * + * @covers ::has + */ + public function test_has_returns_true_for_unknown_text_domain() { + $this->assertTrue( $this->instance->has( 'unknown-plugin' ) ); + } + + /** + * A lookup that found nothing still records the (negative) result for the + * current locale, which keeps has() truthy. + * + * @ticket 62348 + * + * @covers ::has + * @covers ::get + */ + public function test_has_returns_true_after_unsuccessful_lookup() { + $this->assertFalse( + $this->instance->get( 'unknown-plugin', 'de_DE' ), + 'A text domain without translations should not resolve to a path' + ); + $this->assertTrue( + $this->instance->has( 'unknown-plugin' ), + 'The negative result for the current locale should still be reported as available' + ); + } + + /** + * Registering a custom path late must not make previously found translations + * unreachable. + * + * load_plugin_textdomain() and load_theme_textdomain() only register a custom + * path and hand the actual loading off to _load_textdomain_just_in_time(), + * which bails early when has() returns false. + * + * @ticket 62348 + * + * @covers ::has + * @covers ::set_custom_path + */ + public function test_has_after_custom_path_is_registered_following_a_successful_lookup() { + // A first locale resolves to the WordPress languages directory. + $this->assertSame( + WP_LANG_DIR . '/plugins/', + $this->instance->get( 'internationalized-plugin', 'de_DE' ), + 'de_DE translations should be found in the WordPress languages directory' + ); + + // A second locale has no translations at all, so 'current' becomes false. + $this->assertFalse( + $this->instance->get( 'internationalized-plugin', 'fr_FR' ), + 'There should be no fr_FR translations' + ); + + // Only now does the plugin call load_plugin_textdomain(). + $this->instance->set_custom_path( + 'internationalized-plugin', + WP_PLUGIN_DIR . '/custom-internationalized-plugin/languages' + ); + + $this->assertTrue( + $this->instance->has( 'internationalized-plugin' ), + 'Registering a custom path should not hide the already known translations' + ); + $this->assertSame( + WP_LANG_DIR . '/plugins/', + $this->instance->get( 'internationalized-plugin', 'de_DE' ), + 'de_DE translations should still be found after registering a custom path' + ); + } + + /** + * Same as above, except that no locale ever resolved, so there is nothing + * left to remember once the negative results have been discarded. + * + * @ticket 62348 + * + * @covers ::has + * @covers ::set_custom_path + */ + public function test_has_after_custom_path_is_registered_following_an_unsuccessful_lookup() { + $this->assertFalse( + $this->instance->get( 'unknown-plugin', 'de_DE' ), + 'There should be no de_DE translations' + ); + + $this->instance->set_custom_path( + 'unknown-plugin', + WP_PLUGIN_DIR . '/custom-internationalized-plugin/languages' + ); + + $this->assertTrue( + $this->instance->has( 'unknown-plugin' ), + 'The newly registered custom path should be given a chance' + ); + } + + /** + * Text domains are matched in full, not by prefix. + * + * "internationalized-plugin-de_DE.mo" must not count as a translation for the + * "internationalized" text domain just because the file name starts with it. + * + * @ticket 62348 + * + * @covers ::get + * @covers ::has + * @dataProvider data_text_domains_sharing_a_prefix + * + * @param string $domain Text domain that has no translations of its own. + * @param string $locale Locale to look up. + */ + public function test_translations_are_not_shared_between_text_domains_with_a_common_prefix( $domain, $locale ) { + $this->assertFalse( + $this->instance->get( $domain, $locale ), + 'A text domain sharing a prefix with a translated one should not resolve to a path' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_text_domains_sharing_a_prefix() { + return array( + // "internationalized-plugin-{de_DE,es_ES}.mo" exist, "internationalized-*" do not. + 'prefix of a domain with .mo files' => array( 'internationalized', 'de_DE' ), + 'prefix of a domain with .mo files, es' => array( 'internationalized', 'es_ES' ), + // "internationalized-plugin-2-de_DE.l10n.php" exists, but is not a + // translation of "internationalized-plugin". + 'domain whose sibling adds a suffix' => array( 'internationalized-plugin', 'fr_FR' ), + ); + } + + /** + * Once a custom path is registered, the domain must stay resolvable even when + * an unrelated text domain shares its prefix. + * + * @ticket 62348 + * + * @covers ::has + * @covers ::set_custom_path + */ + public function test_has_with_custom_path_is_unaffected_by_text_domains_sharing_a_prefix() { + $this->assertFalse( + $this->instance->get( 'internationalized', 'de_DE' ), + 'The "internationalized" text domain has no translations of its own' + ); + + $this->instance->set_custom_path( + 'internationalized', + WP_PLUGIN_DIR . '/custom-internationalized-plugin/languages' + ); + + $this->assertTrue( + $this->instance->has( 'internationalized' ), + 'The newly registered custom path should be given a chance' + ); + } + public function data_domains_locales() { return array( 'Non-existent plugin' => array(