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
20 changes: 10 additions & 10 deletions src/wp-includes/class-wp-textdomain-registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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.
Expand All @@ -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 ] )
);
}

Expand Down Expand Up @@ -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;
Expand Down
68 changes: 68 additions & 0 deletions tests/phpunit/tests/l10n/loadTextdomainJustInTime.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()' );
}
}
167 changes: 167 additions & 0 deletions tests/phpunit/tests/l10n/wpTextdomainRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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(
Expand Down
Loading