Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cache language file paths #5664

Closed
Closed
140 changes: 118 additions & 22 deletions src/wp-includes/class-wp-textdomain-registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,11 @@ class WP_Textdomain_Registry {
* Holds a cached list of available .mo files to improve performance.
*
* @since 6.1.0
* @since 6.5.0 This property is no longer used.
*
* @var array
*
* @deprecated
*/
protected $cached_mo_files = array();

Expand All @@ -65,6 +68,18 @@ class WP_Textdomain_Registry {
*/
protected $domains_with_translations = array();

/**
* Initializes the registry.
*
* Hooks into the {@see 'upgrader_process_complete'} filter
* to invalidate MO files caches.
*
* @since 6.5.0
*/
public function init() {
add_action( 'upgrader_process_complete', array( $this, 'invalidate_mo_files_cache' ), 10, 2 );
}

/**
* Returns the languages directory path for a specific domain and locale.
*
Expand Down Expand Up @@ -134,6 +149,106 @@ public function set_custom_path( $domain, $path ) {
$this->custom_paths[ $domain ] = rtrim( $path, '/' );
}

/**
* Retrieves .mo files from the specified path.
*
* Allows early retrieval through the {@see 'pre_get_mo_files_from_path'} filter to optimize
* performance, especially in directories with many files.
*
* @since 6.5.0
*
* @param string $path The directory path to search for .mo files.
* @return array Array of .mo file paths.
*/
public function get_language_files_from_path( $path ) {
$path = trailingslashit( $path );

/**
* Filters the .mo files retrieved from a specified path before the actual lookup.
*
* Returning a non-null value from the filter will effectively short-circuit
* the MO files lookup, returning that value instead.
*
* This can be useful in situations where the directory contains a large number of files
* and the default glob() function becomes expensive in terms of performance.
*
* @since 6.5.0
*
* @param null|array $mo_files List of .mo files. Default null.
* @param string $path The path from which .mo files are being fetched.
**/
$mo_files = apply_filters( 'pre_get_language_files_from_path', null, $path );

if ( null !== $mo_files ) {
return $mo_files;
}

$cache_key = 'cached_mo_files_' . md5( $path );
$mo_files = wp_cache_get( $cache_key, 'translations' );

if ( false === $mo_files ) {
$mo_files = glob( $path . '*.mo' );
if ( false === $mo_files ) {
$mo_files = array();
}
wp_cache_set( $cache_key, $mo_files, 'translations' );
}

return $mo_files;
}

/**
* Invalidate the cache for .mo files.
*
* This function deletes the cache entries related to .mo files when triggered
* by specific actions, such as the completion of an upgrade process.
*
* @since 6.5.0
*
* @param WP_Upgrader $upgrader Unused. WP_Upgrader instance. In other contexts this might be a
* Theme_Upgrader, Plugin_Upgrader, Core_Upgrade, or Language_Pack_Upgrader instance.
* @param array $hook_extra {
* Array of bulk item update data.
*
* @type string $action Type of action. Default 'update'.
* @type string $type Type of update process. Accepts 'plugin', 'theme', 'translation', or 'core'.
* @type bool $bulk Whether the update process is a bulk update. Default true.
* @type array $plugins Array of the basename paths of the plugins' main files.
* @type array $themes The theme slugs.
* @type array $translations {
* Array of translations update data.
*
* @type string $language The locale the translation is for.
* @type string $type Type of translation. Accepts 'plugin', 'theme', or 'core'.
* @type string $slug Text domain the translation is for. The slug of a theme/plugin or
* 'default' for core translations.
* @type string $version The version of a theme, plugin, or core.
* }
* }
* @return void
*/
public function invalidate_mo_files_cache( $upgrader, $hook_extra ) {
if ( 'translation' !== $hook_extra['type'] || array() === $hook_extra['translations'] ) {
return;
}

$translation_types = array_unique( wp_list_pluck( $hook_extra['translations'], 'type' ) );

foreach ( $translation_types as $type ) {
switch ( $type ) {
case 'plugin':
wp_cache_delete( 'cached_mo_files_' . md5( trailingslashit( WP_LANG_DIR ) . '/plugins/' ), 'translations' );
break;
case 'theme':
wp_cache_delete( 'cached_mo_files_' . md5( trailingslashit( WP_LANG_DIR ) . '/themes/' ), 'translations' );
break;
default:
wp_cache_delete( 'cached_mo_files_' . md5( trailingslashit( WP_LANG_DIR ) ), 'translations' );
break;
}
}
}

/**
* Returns possible language directory paths for a given text domain.
*
Expand All @@ -156,7 +271,7 @@ private function get_paths_for_domain( $domain ) {
}

/**
* Gets the path to the language directory for the current locale.
* Gets the path to the language directory for the current domain and 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()}.
Expand All @@ -175,13 +290,11 @@ private function get_path_from_lang_dir( $domain, $locale ) {
$found_location = false;

foreach ( $locations as $location ) {
if ( ! isset( $this->cached_mo_files[ $location ] ) ) {
$this->set_cached_mo_files( $location );
}
$files = $this->get_language_files_from_path( $location );

$path = "$location/$domain-$locale.mo";

foreach ( $this->cached_mo_files[ $location ] as $mo_path ) {
foreach ( $files as $mo_path ) {
if (
! in_array( $domain, $this->domains_with_translations, true ) &&
str_starts_with( str_replace( "$location/", '', $mo_path ), "$domain-" )
Expand Down Expand Up @@ -215,21 +328,4 @@ private function get_path_from_lang_dir( $domain, $locale ) {

return false;
}

/**
* Reads and caches all available MO files from a given directory.
*
* @since 6.1.0
*
* @param string $path Language directory path.
*/
private function set_cached_mo_files( $path ) {
$this->cached_mo_files[ $path ] = array();

$mo_files = glob( $path . '/*.mo' );

if ( $mo_files ) {
$this->cached_mo_files[ $path ] = $mo_files;
}
}
}
8 changes: 7 additions & 1 deletion src/wp-includes/l10n.php
Original file line number Diff line number Diff line change
Expand Up @@ -1389,15 +1389,21 @@ function translate_user_role( $name, $domain = 'default' ) {
* @since 3.0.0
* @since 4.7.0 The results are now filterable with the {@see 'get_available_languages'} filter.
*
* @global WP_Textdomain_Registry $wp_textdomain_registry WordPress Textdomain Registry.
*
* @param string $dir A directory to search for language files.
* Default WP_LANG_DIR.
* @return string[] An array of language codes or an empty array if no languages are present.
* Language codes are formed by stripping the .mo extension from the language file names.
*/
function get_available_languages( $dir = null ) {
global $wp_textdomain_registry;

$languages = array();

$lang_files = glob( ( is_null( $dir ) ? WP_LANG_DIR : $dir ) . '/*.mo' );
$path = is_null( $dir ) ? WP_LANG_DIR : $dir;
$lang_files = $wp_textdomain_registry->get_language_files_from_path( $path );

if ( $lang_files ) {
foreach ( $lang_files as $lang_file ) {
$lang_file = basename( $lang_file, '.mo' );
Expand Down
1 change: 1 addition & 0 deletions src/wp-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@
* @global WP_Textdomain_Registry $wp_textdomain_registry WordPress Textdomain Registry.
*/
$GLOBALS['wp_textdomain_registry'] = new WP_Textdomain_Registry();
$GLOBALS['wp_textdomain_registry']->init();

// Load multisite-specific files.
if ( is_multisite() ) {
Expand Down
104 changes: 81 additions & 23 deletions tests/phpunit/tests/l10n/wpTextdomainRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,21 @@ public function set_up() {
$this->instance = new WP_Textdomain_Registry();
}

public function tear_down() {
wp_cache_delete( 'cached_mo_files_' . md5( trailingslashit( WP_LANG_DIR ) . '/foobar/' ), 'translations' );
wp_cache_delete( 'cached_mo_files_' . md5( trailingslashit( WP_LANG_DIR ) . '/plugins/' ), 'translations' );
wp_cache_delete( 'cached_mo_files_' . md5( trailingslashit( WP_LANG_DIR ) . '/themes/' ), 'translations' );
wp_cache_delete( 'cached_mo_files_' . md5( trailingslashit( WP_LANG_DIR ) ), 'translations' );

parent::tear_down();
}

/**
* @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->assertEmpty(
$reflection_property->getValue( $this->instance ),
'Cache not empty by default'
);

$this->instance->set_custom_path( 'foo', WP_LANG_DIR . '/bar' );

$this->assertTrue(
Expand All @@ -48,10 +48,9 @@ public function test_set_custom_path() {
$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'
$this->assertNotFalse(
wp_cache_get( 'cached_mo_files_' . md5( trailingslashit( WP_LANG_DIR ) . 'bar/' ), 'translations' ),
'List of files in custom path not cached'
);
}

Expand All @@ -60,22 +59,12 @@ public function test_set_custom_path() {
* @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'
);
}

/**
Expand All @@ -91,6 +80,75 @@ public function test_set_populates_cache() {
);
}

/**
* @covers ::get_language_files_from_path
*/
public function test_get_language_files_from_path_caches_results() {
$this->instance->get_language_files_from_path( trailingslashit( WP_LANG_DIR ) . '/foobar/' );
$this->instance->get_language_files_from_path( trailingslashit( WP_LANG_DIR ) . '/plugins/' );
$this->instance->get_language_files_from_path( trailingslashit( WP_LANG_DIR ) . '/themes/' );
$this->instance->get_language_files_from_path( trailingslashit( WP_LANG_DIR ) );

$this->assertNotFalse( wp_cache_get( 'cached_mo_files_' . md5( trailingslashit( WP_LANG_DIR ) . '/plugins/' ), 'translations' ) );
$this->assertNotFalse( wp_cache_get( 'cached_mo_files_' . md5( trailingslashit( WP_LANG_DIR ) . '/themes/' ), 'translations' ) );
$this->assertNotFalse( wp_cache_get( 'cached_mo_files_' . md5( trailingslashit( WP_LANG_DIR ) . '/foobar/' ), 'translations' ) );
$this->assertNotFalse( wp_cache_get( 'cached_mo_files_' . md5( trailingslashit( WP_LANG_DIR ) ), 'translations' ) );
}

/**
* @covers ::get_language_files_from_path
*/
public function test_get_language_files_from_path_short_circuit() {
add_filter( 'pre_get_language_files_from_path', '__return_empty_array' );
$result = $this->instance->get_language_files_from_path( trailingslashit( WP_LANG_DIR ) . '/plugins/' );
remove_filter( 'pre_get_language_files_from_path', '__return_empty_array' );

$cache = wp_cache_get( 'cached_mo_files_' . md5( trailingslashit( WP_LANG_DIR ) . '/plugins/' ), 'translations' );

$this->assertEmpty( $result );
$this->assertFalse( $cache );
}

/**
* @covers ::invalidate_mo_files_cache
*/
public function test_invalidate_mo_files_cache() {
$this->instance->get_language_files_from_path( trailingslashit( WP_LANG_DIR ) . '/plugins/' );
$this->instance->get_language_files_from_path( trailingslashit( WP_LANG_DIR ) . '/themes/' );
$this->instance->get_language_files_from_path( trailingslashit( WP_LANG_DIR ) );

$this->instance->invalidate_mo_files_cache(
null,
array(
'type' => 'translation',
'translations' => array(
(object) array(
'type' => 'plugin',
'slug' => 'internationalized-plugin',
'language' => 'de_DE',
'version' => '99.9.9',
),
(object) array(
'type' => 'theme',
'slug' => 'internationalized-theme',
'language' => 'de_DE',
'version' => '99.9.9',
),
(object) array(
'type' => 'core',
'slug' => 'default',
'language' => 'es_ES',
'version' => '99.9.9',
),
),
)
);

$this->assertFalse( wp_cache_get( 'cached_mo_files_' . md5( trailingslashit( WP_LANG_DIR ) . '/plugins/' ), 'translations' ) );
$this->assertFalse( wp_cache_get( 'cached_mo_files_' . md5( trailingslashit( WP_LANG_DIR ) . '/themes/' ), 'translations' ) );
$this->assertFalse( wp_cache_get( 'cached_mo_files_' . md5( trailingslashit( WP_LANG_DIR ) ), 'translations' ) );
}

public function data_domains_locales() {
return array(
'Non-existent plugin' => array(
Expand Down
Loading