I18N: Introduce a more performant localization library.

This introduces a more lightweight library for loading `.mo` translation files which offers increased speed and lower memory usage.
It also supports loading multiple locales at the same time, which makes locale switching faster too.

For plugins interacting with the `$l10n` global variable in core, a shim is added to retain backward compatibility with the existing `pomo` library.

In addition to that, this library supports translations contained in PHP files, avoiding a binary file format and leveraging OPCache if available.
If an `.mo` translation file has a corresponding `.l10n.php` file, the latter will be loaded instead.
This behavior can be adjusted using the new `translation_file_format` and `load_translation_file` filters.

PHP translation files will be typically created by downloading language packs, but can also be generated by plugins.
See https://make.wordpress.org/core/2023/11/08/merging-performant-translations-into-core/ for more context.

Props dd32, swissspidy, flixos90, joemcgill, westonruter, akirk, SergeyBiryukov.
Fixes #59656.
Built from https://develop.svn.wordpress.org/trunk@57337


git-svn-id: http://core.svn.wordpress.org/trunk@56843 1a063a9b-81f0-0310-95a4-ce76da25c4cd
This commit is contained in:
Pascal Birchler 2024-01-23 13:34:11 +00:00
parent a63a6ae7ee
commit c6ccfb1242
12 changed files with 1277 additions and 12 deletions

View File

@ -1009,6 +1009,7 @@ function delete_plugins( $plugins, $deprecated = '' ) {
foreach ( $translations as $translation => $data ) {
$wp_filesystem->delete( WP_LANG_DIR . '/plugins/' . $plugin_slug . '-' . $translation . '.po' );
$wp_filesystem->delete( WP_LANG_DIR . '/plugins/' . $plugin_slug . '-' . $translation . '.mo' );
$wp_filesystem->delete( WP_LANG_DIR . '/plugins/' . $plugin_slug . '-' . $translation . '.l10n.php' );
$json_translation_files = glob( WP_LANG_DIR . '/plugins/' . $plugin_slug . '-' . $translation . '-*.json' );
if ( $json_translation_files ) {

View File

@ -283,6 +283,8 @@ class WP_Locale_Switcher {
$wp_locale = new WP_Locale();
WP_Translation_Controller::instance()->set_locale( $locale );
/**
* Fires when the locale is switched to or restored.
*

View File

@ -420,6 +420,38 @@ if ( ! function_exists( 'array_key_last' ) ) {
}
}
if ( ! function_exists( 'array_is_list' ) ) {
/**
* Polyfill for `array_is_list()` function added in PHP 8.1.
*
* Determines if the given array is a list.
*
* An array is considered a list if its keys consist of consecutive numbers from 0 to count($array)-1.
*
* @see https://github.com/symfony/polyfill-php81/tree/main
*
* @since 6.5.0
*
* @param array<mixed> $arr The array being evaluated.
* @return bool True if array is a list, false otherwise.
*/
function array_is_list( $arr ) {
if ( ( array() === $arr ) || ( array_values( $arr ) === $arr ) ) {
return true;
}
$next_key = -1;
foreach ( $arr as $k => $v ) {
if ( ++$next_key !== $k ) {
return false;
}
}
return true;
}
}
if ( ! function_exists( 'str_contains' ) ) {
/**
* Polyfill for `str_contains()` function added in PHP 8.0.

View File

@ -6550,7 +6550,7 @@ function wp_timezone_choice( $selected_zone, $locale = null ) {
if ( ! $mo_loaded || $locale !== $locale_loaded ) {
$locale_loaded = $locale ? $locale : get_locale();
$mofile = WP_LANG_DIR . '/continents-cities-' . $locale_loaded . '.mo';
unload_textdomain( 'continents-cities' );
unload_textdomain( 'continents-cities', true );
load_textdomain( 'continents-cities', $mofile, $locale_loaded );
$mo_loaded = true;
}

View File

@ -797,22 +797,65 @@ function load_textdomain( $domain, $mofile, $locale = null ) {
$locale = determine_locale();
}
$mo = new MO();
if ( ! $mo->import_from_file( $mofile ) ) {
$wp_textdomain_registry->set( $domain, $locale, false );
$i18n_controller = WP_Translation_Controller::instance();
return false;
// Ensures the correct locale is set as the current one, in case it was filtered.
$i18n_controller->set_locale( $locale );
/**
* Filters the preferred file format for translation files.
*
* Can be used to disable the use of PHP files for translations.
*
* @since 6.5.0
*
* @param string $preferred_format Preferred file format. Possible values: 'php', 'mo'. Default: 'php'.
* @param string $domain The text domain.
*/
$preferred_format = apply_filters( 'translation_file_format', 'php', $domain );
if ( ! in_array( $preferred_format, array( 'php', 'mo' ), true ) ) {
$preferred_format = 'php';
}
if ( isset( $l10n[ $domain ] ) ) {
$mo->merge_with( $l10n[ $domain ] );
$translation_files = array( $mofile );
if ( 'mo' !== $preferred_format ) {
array_unshift(
$translation_files,
substr_replace( $mofile, '.l10n.', - strlen( $preferred_format ) )
);
}
unset( $l10n_unloaded[ $domain ] );
foreach ( $translation_files as $file ) {
/**
* Filters the file path for loading translations for the given text domain.
*
* Similar to the {@see 'load_textdomain_mofile'} filter with the difference that
* the file path could be for an MO or PHP file.
*
* @since 6.5.0
*
* @param string $file Path to the translation file to load.
* @param string $domain The text domain.
*/
$file = (string) apply_filters( 'load_translation_file', $file, $domain );
$l10n[ $domain ] = &$mo;
$success = $i18n_controller->load_file( $file, $domain, $locale );
$wp_textdomain_registry->set( $domain, $locale, dirname( $mofile ) );
if ( $success ) {
if ( isset( $l10n[ $domain ] ) && $l10n[ $domain ] instanceof MO ) {
$i18n_controller->load_file( $l10n[ $domain ]->get_filename(), $domain, $locale );
}
// Unset NOOP_Translations reference in get_translations_for_domain().
unset( $l10n[ $domain ] );
$l10n[ $domain ] = new WP_Translations( $i18n_controller, $domain );
$wp_textdomain_registry->set( $domain, $locale, dirname( $file ) );
return true;
}
}
return true;
}
@ -866,6 +909,11 @@ function unload_textdomain( $domain, $reloadable = false ) {
*/
do_action( 'unload_textdomain', $domain, $reloadable );
// Since multiple locales are supported, reloadable text domains don't actually need to be unloaded.
if ( ! $reloadable ) {
WP_Translation_Controller::instance()->unload_textdomain( $domain );
}
if ( isset( $l10n[ $domain ] ) ) {
if ( $l10n[ $domain ] instanceof NOOP_Translations ) {
unset( $l10n[ $domain ] );
@ -904,7 +952,7 @@ function load_default_textdomain( $locale = null ) {
}
// Unload previously loaded strings so we can switch translations.
unload_textdomain( 'default' );
unload_textdomain( 'default', true );
$return = load_textdomain( 'default', WP_LANG_DIR . "/$locale.mo", $locale );

View File

@ -0,0 +1,420 @@
<?php
/**
* I18N: WP_Translation_Controller class.
*
* @package WordPress
* @subpackage I18N
* @since 6.5.0
*/
/**
* Class WP_Translation_Controller.
*
* @since 6.5.0
*/
final class WP_Translation_Controller {
/**
* Current locale.
*
* @since 6.5.0
* @var string
*/
protected $current_locale = 'en_US';
/**
* Map of loaded translations per locale and text domain.
*
* [ Locale => [ Textdomain => [ ..., ... ] ] ]
*
* @since 6.5.0
* @var array<string, array<string, WP_Translation_File[]>>
*/
protected $loaded_translations = array();
/**
* List of loaded translation files.
*
* [ Filename => [ Locale => [ Textdomain => WP_Translation_File ] ] ]
*
* @since 6.5.0
* @var array<string, array<string, array<string, WP_Translation_File|false>>>
*/
protected $loaded_files = array();
/**
* Returns the WP_Translation_Controller singleton.
*
* @since 6.5.0
*
* @return WP_Translation_Controller
*/
public static function instance(): WP_Translation_Controller {
static $instance;
if ( ! $instance ) {
$instance = new self();
}
return $instance;
}
/**
* Returns the current locale.
*
* @since 6.5.0
*
* @return string Locale.
*/
public function get_locale(): string {
return $this->current_locale;
}
/**
* Sets the current locale.
*
* @since 6.5.0
*
* @param string $locale Locale.
*/
public function set_locale( string $locale ) {
$this->current_locale = $locale;
}
/**
* Loads a translation file for a given text domain.
*
* @since 6.5.0
*
* @param string $translation_file Translation file.
* @param string $textdomain Optional. Text domain. Default 'default'.
* @param string $locale Optional. Locale. Default current locale.
* @return bool True on success, false otherwise.
*/
public function load_file( string $translation_file, string $textdomain = 'default', string $locale = null ): bool {
if ( null === $locale ) {
$locale = $this->current_locale;
}
$translation_file = realpath( $translation_file );
if ( false === $translation_file ) {
return false;
}
if (
isset( $this->loaded_files[ $translation_file ][ $locale ][ $textdomain ] ) &&
false !== $this->loaded_files[ $translation_file ][ $locale ][ $textdomain ]
) {
return null === $this->loaded_files[ $translation_file ][ $locale ][ $textdomain ]->error();
}
if (
isset( $this->loaded_files[ $translation_file ][ $locale ] ) &&
array() !== $this->loaded_files[ $translation_file ][ $locale ]
) {
$moe = reset( $this->loaded_files[ $translation_file ][ $locale ] );
} else {
$moe = WP_Translation_File::create( $translation_file );
if ( false === $moe || null !== $moe->error() ) {
$moe = false;
}
}
$this->loaded_files[ $translation_file ][ $locale ][ $textdomain ] = $moe;
if ( ! $moe instanceof WP_Translation_File ) {
return false;
}
if ( ! isset( $this->loaded_translations[ $locale ][ $textdomain ] ) ) {
$this->loaded_translations[ $locale ][ $textdomain ] = array();
}
$this->loaded_translations[ $locale ][ $textdomain ][] = $moe;
return true;
}
/**
* Unloads a translation file for a given text domain.
*
* @since 6.5.0
*
* @param WP_Translation_File|string $file Translation file instance or file name.
* @param string $textdomain Optional. Text domain. Default 'default'.
* @param string $locale Optional. Locale. Defaults to all locales.
* @return bool True on success, false otherwise.
*/
public function unload_file( $file, string $textdomain = 'default', string $locale = null ): bool {
if ( is_string( $file ) ) {
$file = realpath( $file );
}
if ( null !== $locale ) {
foreach ( $this->loaded_translations[ $locale ][ $textdomain ] as $i => $moe ) {
if ( $file === $moe || $file === $moe->get_file() ) {
unset( $this->loaded_translations[ $locale ][ $textdomain ][ $i ] );
unset( $this->loaded_files[ $moe->get_file() ][ $locale ][ $textdomain ] );
return true;
}
}
return true;
}
foreach ( $this->loaded_translations as $l => $domains ) {
foreach ( $domains[ $textdomain ] as $i => $moe ) {
if ( $file === $moe || $file === $moe->get_file() ) {
unset( $this->loaded_translations[ $l ][ $textdomain ][ $i ] );
unset( $this->loaded_files[ $moe->get_file() ][ $l ][ $textdomain ] );
return true;
}
}
}
return false;
}
/**
* Unloads all translation files for a given text domain.
*
* @since 6.5.0
*
* @param string $textdomain Optional. Text domain. Default 'default'.
* @param string $locale Optional. Locale. Defaults to all locales.
* @return bool True on success, false otherwise.
*/
public function unload_textdomain( string $textdomain = 'default', string $locale = null ): bool {
if ( null !== $locale ) {
foreach ( $this->loaded_translations[ $locale ][ $textdomain ] as $moe ) {
unset( $this->loaded_files[ $moe->get_file() ][ $locale ][ $textdomain ] );
}
unset( $this->loaded_translations[ $locale ][ $textdomain ] );
return true;
}
$unloaded = false;
foreach ( $this->loaded_translations as $l => $domains ) {
if ( ! isset( $domains[ $textdomain ] ) ) {
continue;
}
$unloaded = true;
foreach ( $domains[ $textdomain ] as $moe ) {
unset( $this->loaded_files[ $moe->get_file() ][ $l ][ $textdomain ] );
}
unset( $this->loaded_translations[ $l ][ $textdomain ] );
}
return $unloaded;
}
/**
* Determines whether translations are loaded for a given text domain.
*
* @since 6.5.0
*
* @param string $textdomain Optional. Text domain. Default 'default'.
* @param string $locale Optional. Locale. Default current locale.
* @return bool True if there are any loaded translations, false otherwise.
*/
public function is_textdomain_loaded( string $textdomain = 'default', string $locale = null ): bool {
if ( null === $locale ) {
$locale = $this->current_locale;
}
return isset( $this->loaded_translations[ $locale ][ $textdomain ] ) &&
array() !== $this->loaded_translations[ $locale ][ $textdomain ];
}
/**
* Translates a singular string.
*
* @since 6.5.0
*
* @param string $text Text to translate.
* @param string $context Optional. Context for the string. Default empty string.
* @param string $textdomain Optional. Text domain. Default 'default'.
* @param string $locale Optional. Locale. Default current locale.
* @return string|false Translation on success, false otherwise.
*/
public function translate( string $text, string $context = '', string $textdomain = 'default', string $locale = null ) {
if ( '' !== $context ) {
$context .= "\4";
}
$translation = $this->locate_translation( "{$context}{$text}", $textdomain, $locale );
if ( false === $translation ) {
return false;
}
return $translation['entries'][0];
}
/**
* Translates plurals.
*
* Checks both singular+plural combinations as well as just singulars,
* in case the translation file does not store the plural.
*
* @since 6.5.0
*
* @param array{0: string, 1: string} $plurals {
* Pair of singular and plural translations.
*
* @type string $0 Singular translation.
* @type string $1 Plural translation.
* }
* @param int $number Number of items.
* @param string $context Optional. Context for the string. Default empty string.
* @param string $textdomain Optional. Text domain. Default 'default'.
* @param string $locale Optional. Locale. Default current locale.
* @return string|false Translation on success, false otherwise.
*/
public function translate_plural( array $plurals, int $number, string $context = '', string $textdomain = 'default', string $locale = null ) {
if ( '' !== $context ) {
$context .= "\4";
}
$text = implode( "\0", $plurals );
$translation = $this->locate_translation( "{$context}{$text}", $textdomain, $locale );
if ( false === $translation ) {
$text = $plurals[0];
$translation = $this->locate_translation( "{$context}{$text}", $textdomain, $locale );
if ( false === $translation ) {
return false;
}
}
/** @var WP_Translation_File $source */
$source = $translation['source'];
$num = $source->get_plural_form( $number );
// See \Translations::translate_plural().
return $translation['entries'][ $num ] ?? $translation['entries'][0];
}
/**
* Returns all existing headers for a given text domain.
*
* @since 6.5.0
*
* @param string $textdomain Optional. Text domain. Default 'default'.
* @return array<string, string> Headers.
*/
public function get_headers( string $textdomain = 'default' ): array {
if ( array() === $this->loaded_translations ) {
return array();
}
$headers = array();
foreach ( $this->get_files( $textdomain ) as $moe ) {
foreach ( $moe->headers() as $header => $value ) {
$headers[ $this->normalize_header( $header ) ] = $value;
}
}
return $headers;
}
/**
* Normalizes header names to be capitalized.
*
* @since 6.5.0
*
* @param string $header Header name.
* @return string Normalized header name.
*/
protected function normalize_header( string $header ): string {
$parts = explode( '-', $header );
$parts = array_map( 'ucfirst', $parts );
return implode( '-', $parts );
}
/**
* Returns all entries for a given text domain.
*
* @since 6.5.0
*
* @param string $textdomain Optional. Text domain. Default 'default'.
* @return array<string, string> Entries.
*/
public function get_entries( string $textdomain = 'default' ): array {
if ( array() === $this->loaded_translations ) {
return array();
}
$entries = array();
foreach ( $this->get_files( $textdomain ) as $moe ) {
$entries = array_merge( $entries, $moe->entries() );
}
return $entries;
}
/**
* Locates translation for a given string and text domain.
*
* @since 6.5.0
*
* @param string $singular Singular translation.
* @param string $textdomain Optional. Text domain. Default 'default'.
* @param string $locale Optional. Locale. Default current locale.
* @return array{source: WP_Translation_File, entries: string[]}|false {
* Translations on success, false otherwise.
*
* @type WP_Translation_File $source Translation file instance.
* @type string[] $entries Array of translation entries.
* }
*/
protected function locate_translation( string $singular, string $textdomain = 'default', string $locale = null ) {
if ( array() === $this->loaded_translations ) {
return false;
}
// Find the translation in all loaded files for this text domain.
foreach ( $this->get_files( $textdomain, $locale ) as $moe ) {
$translation = $moe->translate( $singular );
if ( false !== $translation ) {
return array(
'entries' => explode( "\0", $translation ),
'source' => $moe,
);
}
if ( null !== $moe->error() ) {
// Unload this file, something is wrong.
$this->unload_file( $moe, $textdomain, $locale );
}
}
// Nothing could be found.
return false;
}
/**
* Returns all translation files for a given text domain.
*
* @since 6.5.0
*
* @param string $textdomain Optional. Text domain. Default 'default'.
* @param string $locale Optional. Locale. Default current locale.
* @return WP_Translation_File[] List of translation files.
*/
protected function get_files( string $textdomain = 'default', string $locale = null ): array {
if ( null === $locale ) {
$locale = $this->current_locale;
}
return $this->loaded_translations[ $locale ][ $textdomain ] ?? array();
}
}

View File

@ -0,0 +1,219 @@
<?php
/**
* I18N: WP_Translation_File_MO class.
*
* @package WordPress
* @subpackage I18N
* @since 6.5.0
*/
/**
* Class WP_Translation_File_MO.
*
* @since 6.5.0
*/
class WP_Translation_File_MO extends WP_Translation_File {
/**
* Endian value.
*
* V for little endian, N for big endian, or false.
*
* Used for unpack().
*
* @since 6.5.0
* @var false|'V'|'N'
*/
protected $uint32 = false;
/**
* The magic number of the GNU message catalog format.
*
* @since 6.5.0
* @var int
*/
const MAGIC_MARKER = 0x950412de;
/**
* Detects endian and validates file.
*
* @since 6.5.0
*
* @param string $header File contents.
* @return false|'V'|'N' V for little endian, N for big endian, or false on failure.
*/
protected function detect_endian_and_validate_file( string $header ) {
$big = unpack( 'N', $header );
if ( false === $big ) {
return false;
}
$big = reset( $big );
if ( false === $big ) {
return false;
}
$little = unpack( 'V', $header );
if ( false === $little ) {
return false;
}
$little = reset( $little );
if ( false === $little ) {
return false;
}
if ( self::MAGIC_MARKER === $big ) {
return 'N';
}
if ( self::MAGIC_MARKER === $little ) {
return 'V';
}
$this->error = 'Magic marker does not exist';
return false;
}
/**
* Parses the file.
*
* @since 6.5.0
*
* @return bool True on success, false otherwise.
*/
protected function parse_file(): bool {
$this->parsed = true;
$file_contents = file_get_contents( $this->file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
if ( false === $file_contents ) {
return false;
}
$file_length = strlen( $file_contents );
if ( $file_length < 24 ) {
$this->error = 'Invalid data';
return false;
}
$this->uint32 = $this->detect_endian_and_validate_file( substr( $file_contents, 0, 4 ) );
if ( false === $this->uint32 ) {
return false;
}
$offsets = substr( $file_contents, 4, 24 );
if ( false === $offsets ) {
return false;
}
$offsets = unpack( "{$this->uint32}rev/{$this->uint32}total/{$this->uint32}originals_addr/{$this->uint32}translations_addr/{$this->uint32}hash_length/{$this->uint32}hash_addr", $offsets );
if ( false === $offsets ) {
return false;
}
$offsets['originals_length'] = $offsets['translations_addr'] - $offsets['originals_addr'];
$offsets['translations_length'] = $offsets['hash_addr'] - $offsets['translations_addr'];
if ( $offsets['rev'] > 0 ) {
$this->error = 'Unsupported revision';
return false;
}
if ( $offsets['translations_addr'] > $file_length || $offsets['originals_addr'] > $file_length ) {
$this->error = 'Invalid data';
return false;
}
// Load the Originals.
$original_data = str_split( substr( $file_contents, $offsets['originals_addr'], $offsets['originals_length'] ), 8 );
$translations_data = str_split( substr( $file_contents, $offsets['translations_addr'], $offsets['translations_length'] ), 8 );
foreach ( array_keys( $original_data ) as $i ) {
$o = unpack( "{$this->uint32}length/{$this->uint32}pos", $original_data[ $i ] );
$t = unpack( "{$this->uint32}length/{$this->uint32}pos", $translations_data[ $i ] );
if ( false === $o || false === $t ) {
continue;
}
$original = substr( $file_contents, $o['pos'], $o['length'] );
$translation = substr( $file_contents, $t['pos'], $t['length'] );
// GlotPress bug.
$translation = rtrim( $translation, "\0" );
// Metadata about the MO file is stored in the first translation entry.
if ( '' === $original ) {
foreach ( explode( "\n", $translation ) as $meta_line ) {
if ( '' === $meta_line ) {
continue;
}
list( $name, $value ) = array_map( 'trim', explode( ':', $meta_line, 2 ) );
$this->headers[ strtolower( $name ) ] = $value;
}
} else {
$this->entries[ (string) $original ] = $translation;
}
}
return true;
}
/**
* Exports translation contents as a string.
*
* @since 6.5.0
*
* @return string Translation file contents.
*/
public function export(): string {
// Prefix the headers as the first key.
$headers_string = '';
foreach ( $this->headers as $header => $value ) {
$headers_string .= "{$header}: $value\n";
}
$entries = array_merge( array( '' => $headers_string ), $this->entries );
$entry_count = count( $entries );
if ( false === $this->uint32 ) {
$this->uint32 = 'V';
}
$bytes_for_entries = $entry_count * 4 * 2;
// Pair of 32bit ints per entry.
$originals_addr = 28; /* header */
$translations_addr = $originals_addr + $bytes_for_entries;
$hash_addr = $translations_addr + $bytes_for_entries;
$entry_offsets = $hash_addr;
$file_header = pack( $this->uint32 . '*', self::MAGIC_MARKER, 0 /* rev */, $entry_count, $originals_addr, $translations_addr, 0 /* hash_length */, $hash_addr );
$o_entries = '';
$t_entries = '';
$o_addr = '';
$t_addr = '';
foreach ( array_keys( $entries ) as $original ) {
$o_addr .= pack( $this->uint32 . '*', strlen( $original ), $entry_offsets );
$entry_offsets += strlen( $original ) + 1;
$o_entries .= $original . "\0";
}
foreach ( $entries as $translations ) {
$t_addr .= pack( $this->uint32 . '*', strlen( $translations ), $entry_offsets );
$entry_offsets += strlen( $translations ) + 1;
$t_entries .= $translations . "\0";
}
return $file_header . $o_addr . $t_addr . $o_entries . $t_entries;
}
}

View File

@ -0,0 +1,83 @@
<?php
/**
* I18N: WP_Translation_File_PHP class.
*
* @package WordPress
* @subpackage I18N
* @since 6.5.0
*/
/**
* Class WP_Translation_File_PHP.
*
* @since 6.5.0
*/
class WP_Translation_File_PHP extends WP_Translation_File {
/**
* Parses the file.
*
* @since 6.5.0
*/
protected function parse_file() {
$this->parsed = true;
$result = include $this->file;
if ( ! $result || ! is_array( $result ) ) {
$this->error = 'Invalid data';
return;
}
if ( isset( $result['messages'] ) && is_array( $result['messages'] ) ) {
foreach ( $result['messages'] as $singular => $translations ) {
if ( is_array( $translations ) ) {
$this->entries[ $singular ] = implode( "\0", $translations );
} elseif ( is_string( $translations ) ) {
$this->entries[ $singular ] = $translations;
}
}
unset( $result['messages'] );
}
$this->headers = array_change_key_case( $result );
}
/**
* Exports translation contents as a string.
*
* @since 6.5.0
*
* @return string Translation file contents.
*/
public function export(): string {
$data = array_merge( $this->headers, array( 'messages' => $this->entries ) );
return '<?php' . PHP_EOL . 'return ' . $this->var_export( $data ) . ';' . PHP_EOL;
}
/**
* Outputs or returns a parsable string representation of a variable.
*
* Like {@see var_export()} but "minified", using short array syntax
* and no newlines.
*
* @since 6.5.0
*
* @param mixed $value The variable you want to export.
* @return string The variable representation.
*/
private function var_export( $value ): string {
if ( ! is_array( $value ) ) {
return var_export( $value, true );
}
$entries = array();
$is_list = array_is_list( $value );
foreach ( $value as $key => $val ) {
$entries[] = $is_list ? $this->var_export( $val ) : var_export( $key, true ) . '=>' . $this->var_export( $val );
}
return '[' . implode( ',', $entries ) . ']';
}
}

View File

@ -0,0 +1,296 @@
<?php
/**
* I18N: WP_Translation_File class.
*
* @package WordPress
* @subpackage I18N
* @since 6.5.0
*/
/**
* Class WP_Translation_File.
*
* @since 6.5.0
*/
abstract class WP_Translation_File {
/**
* List of headers.
*
* @since 6.5.0
* @var array<string, string>
*/
protected $headers = array();
/**
* Whether file has been parsed.
*
* @since 6.5.0
* @var bool
*/
protected $parsed = false;
/**
* Error information.
*
* @since 6.5.0
* @var string|null Error message or null if no error.
*/
protected $error;
/**
* File name.
*
* @since 6.5.0
* @var string
*/
protected $file = '';
/**
* Translation entries.
*
* @since 6.5.0
* @var array<string, string>
*/
protected $entries = array();
/**
* Plural forms function.
*
* @since 6.5.0
* @var callable|null Plural forms.
*/
protected $plural_forms = null;
/**
* Constructor.
*
* @since 6.5.0
*
* @param string $file File to load.
*/
protected function __construct( string $file ) {
$this->file = $file;
}
/**
* Creates a new WP_Translation_File instance for a given file.
*
* @since 6.5.0
*
* @param string $file File name.
* @param string|null $filetype Optional. File type. Default inferred from file name.
* @return false|WP_Translation_File
*/
public static function create( string $file, string $filetype = null ) {
if ( ! is_readable( $file ) ) {
return false;
}
if ( null === $filetype ) {
$pos = strrpos( $file, '.' );
if ( false !== $pos ) {
$filetype = substr( $file, $pos + 1 );
}
}
switch ( $filetype ) {
case 'mo':
return new WP_Translation_File_MO( $file );
case 'php':
return new WP_Translation_File_PHP( $file );
default:
return false;
}
}
/**
* Creates a new WP_Translation_File instance for a given file.
*
* @since 6.5.0
*
* @param string $file Source file name.
* @param string $filetype Desired target file type.
* @return string|false Transformed translation file contents on success, false otherwise.
*/
public static function transform( string $file, string $filetype ) {
$source = self::create( $file );
if ( false === $source ) {
return false;
}
switch ( $filetype ) {
case 'mo':
$destination = new WP_Translation_File_MO( '' );
break;
case 'php':
$destination = new WP_Translation_File_PHP( '' );
break;
default:
return false;
}
$success = $destination->import( $source );
if ( ! $success ) {
return false;
}
return $destination->export();
}
/**
* Returns all headers.
*
* @since 6.5.0
*
* @return array<string, string> Headers.
*/
public function headers(): array {
if ( ! $this->parsed ) {
$this->parse_file();
}
return $this->headers;
}
/**
* Returns all entries.
*
* @since 6.5.0
*
* @return array<string, string[]> Entries.
*/
public function entries(): array {
if ( ! $this->parsed ) {
$this->parse_file();
}
return $this->entries;
}
/**
* Returns the current error information.
*
* @since 6.5.0
*
* @return string|null Error message or null if no error.
*/
public function error() {
return $this->error;
}
/**
* Returns the file name.
*
* @since 6.5.0
*
* @return string File name.
*/
public function get_file(): string {
return $this->file;
}
/**
* Translates a given string.
*
* @since 6.5.0
*
* @param string $text String to translate.
* @return false|string Translation(s) on success, false otherwise.
*/
public function translate( string $text ) {
if ( ! $this->parsed ) {
$this->parse_file();
}
return $this->entries[ $text ] ?? false;
}
/**
* Returns the plural form for a count.
*
* @since 6.5.0
*
* @param int $number Count.
* @return int Plural form.
*/
public function get_plural_form( int $number ): int {
if ( ! $this->parsed ) {
$this->parse_file();
}
// In case a plural form is specified as a header, but no function included, build one.
if ( null === $this->plural_forms && isset( $this->headers['plural-forms'] ) ) {
$this->plural_forms = $this->make_plural_form_function( $this->headers['plural-forms'] );
}
if ( is_callable( $this->plural_forms ) ) {
/**
* Plural form.
*
* @var int $result Plural form.
*/
$result = call_user_func( $this->plural_forms, $number );
return $result;
}
// Default plural form matches English, only "One" is considered singular.
return ( 1 === $number ? 0 : 1 );
}
/**
* Makes a function, which will return the right translation index, according to the
* plural forms header.
*
* @since 6.5.0
*
* @param string $expression Plural form expression.
* @return callable(int $num): int Plural forms function.
*/
public function make_plural_form_function( string $expression ): callable {
try {
$handler = new Plural_Forms( rtrim( $expression, ';' ) );
return array( $handler, 'get' );
} catch ( Exception $e ) {
// Fall back to default plural-form function.
return $this->make_plural_form_function( 'n != 1' );
}
}
/**
* Imports translations from another file.
*
* @since 6.5.0
*
* @param WP_Translation_File $source Source file.
* @return bool True on success, false otherwise.
*/
protected function import( WP_Translation_File $source ): bool {
if ( null !== $source->error() ) {
return false;
}
$this->headers = $source->headers();
$this->entries = $source->entries();
$this->error = $source->error();
return null === $this->error;
}
/**
* Parses the file.
*
* @since 6.5.0
*/
abstract protected function parse_file();
/**
* Exports translation contents as a string.
*
* @since 6.5.0
*
* @return string Translation file contents.
*/
abstract public function export();
}

View File

@ -0,0 +1,157 @@
<?php
/**
* I18N: WP_Translations class.
*
* @package WordPress
* @subpackage I18N
* @since 6.5.0
*/
/**
* Class WP_Translations.
*
* @since 6.5.0
*
* @property-read array<string, string> $headers
* @property-read array<string, string[]> $entries
*/
class WP_Translations {
/**
* Text domain.
*
* @since 6.5.0
* @var string
*/
protected $textdomain = 'default';
/**
* Translation controller instance.
*
* @since 6.5.0
* @var WP_Translation_Controller
*/
protected $controller;
/**
* Constructor.
*
* @since 6.5.0
*
* @param WP_Translation_Controller $controller I18N controller.
* @param string $textdomain Optional. Text domain. Default 'default'.
*/
public function __construct( WP_Translation_Controller $controller, string $textdomain = 'default' ) {
$this->controller = $controller;
$this->textdomain = $textdomain;
}
/**
* Magic getter for backward compatibility.
*
* @since 6.5.0
*
* @param string $name Property name.
* @return mixed
*/
public function __get( string $name ) {
if ( 'entries' === $name ) {
$entries = $this->controller->get_entries( $this->textdomain );
$result = array();
foreach ( $entries as $original => $translations ) {
$result[] = $this->make_entry( $original, $translations );
}
return $result;
}
if ( 'headers' === $name ) {
return $this->controller->get_headers( $this->textdomain );
}
return null;
}
/**
* Builds a Translation_Entry from original string and translation strings.
*
* @see MO::make_entry()
*
* @since 6.5.0
*
* @param string $original Original string to translate from MO file. Might contain
* 0x04 as context separator or 0x00 as singular/plural separator.
* @param string $translations Translation strings from MO file.
* @return Translation_Entry Entry instance.
*/
private function make_entry( $original, $translations ): Translation_Entry {
$entry = new Translation_Entry();
// Look for context, separated by \4.
$parts = explode( "\4", $original );
if ( isset( $parts[1] ) ) {
$original = $parts[1];
$entry->context = $parts[0];
}
// Look for plural original.
$parts = explode( "\0", $original );
$entry->singular = $parts[0];
if ( isset( $parts[1] ) ) {
$entry->is_plural = true;
$entry->plural = $parts[1];
}
$entry->translations = explode( "\0", $translations );
return $entry;
}
/**
* Translates a plural string.
*
* @since 6.5.0
*
* @param string|null $singular Singular string.
* @param string|null $plural Plural string.
* @param int|float $count Count. Should be an integer, but some plugins pass floats.
* @param string|null $context Context.
* @return string|null Translation if it exists, or the unchanged singular string.
*/
public function translate_plural( $singular, $plural, $count = 1, $context = '' ) {
if ( null === $singular || null === $plural ) {
return $singular;
}
$translation = $this->controller->translate_plural( array( $singular, $plural ), (int) $count, (string) $context, $this->textdomain );
if ( false !== $translation ) {
return $translation;
}
// Fall back to the original with English grammar rules.
return ( 1 === $count ? $singular : $plural );
}
/**
* Translates a singular string.
*
* @since 6.5.0
*
* @param string|null $singular Singular string.
* @param string|null $context Context.
* @return string|null Translation if it exists, or the unchanged singular string
*/
public function translate( $singular, $context = '' ) {
if ( null === $singular ) {
return null;
}
$translation = $this->controller->translate( $singular, (string) $context, $this->textdomain );
if ( false !== $translation ) {
return $translation;
}
// Fall back to the original.
return $singular;
}
}

View File

@ -16,7 +16,7 @@
*
* @global string $wp_version
*/
$wp_version = '6.5-alpha-57336';
$wp_version = '6.5-alpha-57337';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.

View File

@ -115,6 +115,11 @@ require ABSPATH . WPINC . '/class-wp-matchesmapregex.php';
require ABSPATH . WPINC . '/class-wp.php';
require ABSPATH . WPINC . '/class-wp-error.php';
require ABSPATH . WPINC . '/pomo/mo.php';
require ABSPATH . WPINC . '/l10n/class-wp-translation-controller.php';
require ABSPATH . WPINC . '/l10n/class-wp-translations.php';
require ABSPATH . WPINC . '/l10n/class-wp-translation-file.php';
require ABSPATH . WPINC . '/l10n/class-wp-translation-file-mo.php';
require ABSPATH . WPINC . '/l10n/class-wp-translation-file-php.php';
/**
* @since 0.71
@ -617,6 +622,8 @@ $GLOBALS['wp_locale'] = new WP_Locale();
$GLOBALS['wp_locale_switcher'] = new WP_Locale_Switcher();
$GLOBALS['wp_locale_switcher']->init();
WP_Translation_Controller::instance()->set_locale( $locale );
// Load the functions for the active theme, for both parent and child theme if applicable.
foreach ( wp_get_active_and_valid_themes() as $theme ) {
if ( file_exists( $theme . '/functions.php' ) ) {