From c6ccfb124213fdd4ba3e50fe7d4b12b1fce20187 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 23 Jan 2024 13:34:11 +0000 Subject: [PATCH] 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 --- wp-admin/includes/plugin.php | 1 + wp-includes/class-wp-locale-switcher.php | 2 + wp-includes/compat.php | 32 ++ wp-includes/functions.php | 2 +- wp-includes/l10n.php | 68 ++- .../l10n/class-wp-translation-controller.php | 420 ++++++++++++++++++ .../l10n/class-wp-translation-file-mo.php | 219 +++++++++ .../l10n/class-wp-translation-file-php.php | 83 ++++ .../l10n/class-wp-translation-file.php | 296 ++++++++++++ wp-includes/l10n/class-wp-translations.php | 157 +++++++ wp-includes/version.php | 2 +- wp-settings.php | 7 + 12 files changed, 1277 insertions(+), 12 deletions(-) create mode 100644 wp-includes/l10n/class-wp-translation-controller.php create mode 100644 wp-includes/l10n/class-wp-translation-file-mo.php create mode 100644 wp-includes/l10n/class-wp-translation-file-php.php create mode 100644 wp-includes/l10n/class-wp-translation-file.php create mode 100644 wp-includes/l10n/class-wp-translations.php diff --git a/wp-admin/includes/plugin.php b/wp-admin/includes/plugin.php index f55bbd80eb..123c9d8f5f 100644 --- a/wp-admin/includes/plugin.php +++ b/wp-admin/includes/plugin.php @@ -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 ) { diff --git a/wp-includes/class-wp-locale-switcher.php b/wp-includes/class-wp-locale-switcher.php index d07490f107..b3e163014a 100644 --- a/wp-includes/class-wp-locale-switcher.php +++ b/wp-includes/class-wp-locale-switcher.php @@ -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. * diff --git a/wp-includes/compat.php b/wp-includes/compat.php index 5bfdbc23d6..429c5f92e7 100644 --- a/wp-includes/compat.php +++ b/wp-includes/compat.php @@ -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 $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. diff --git a/wp-includes/functions.php b/wp-includes/functions.php index ff55251d7d..3b321fc0a6 100644 --- a/wp-includes/functions.php +++ b/wp-includes/functions.php @@ -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; } diff --git a/wp-includes/l10n.php b/wp-includes/l10n.php index 726e3da1a5..4f3ec8bfed 100644 --- a/wp-includes/l10n.php +++ b/wp-includes/l10n.php @@ -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 ); diff --git a/wp-includes/l10n/class-wp-translation-controller.php b/wp-includes/l10n/class-wp-translation-controller.php new file mode 100644 index 0000000000..fbe5fa7d0c --- /dev/null +++ b/wp-includes/l10n/class-wp-translation-controller.php @@ -0,0 +1,420 @@ + [ Textdomain => [ ..., ... ] ] ] + * + * @since 6.5.0 + * @var array> + */ + protected $loaded_translations = array(); + + /** + * List of loaded translation files. + * + * [ Filename => [ Locale => [ Textdomain => WP_Translation_File ] ] ] + * + * @since 6.5.0 + * @var array>> + */ + 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 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 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(); + } +} diff --git a/wp-includes/l10n/class-wp-translation-file-mo.php b/wp-includes/l10n/class-wp-translation-file-mo.php new file mode 100644 index 0000000000..225b48a836 --- /dev/null +++ b/wp-includes/l10n/class-wp-translation-file-mo.php @@ -0,0 +1,219 @@ +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; + } +} diff --git a/wp-includes/l10n/class-wp-translation-file-php.php b/wp-includes/l10n/class-wp-translation-file-php.php new file mode 100644 index 0000000000..9f5b5abd98 --- /dev/null +++ b/wp-includes/l10n/class-wp-translation-file-php.php @@ -0,0 +1,83 @@ +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 '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 ) . ']'; + } +} diff --git a/wp-includes/l10n/class-wp-translation-file.php b/wp-includes/l10n/class-wp-translation-file.php new file mode 100644 index 0000000000..61efd98270 --- /dev/null +++ b/wp-includes/l10n/class-wp-translation-file.php @@ -0,0 +1,296 @@ + + */ + 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 + */ + 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 Headers. + */ + public function headers(): array { + if ( ! $this->parsed ) { + $this->parse_file(); + } + return $this->headers; + } + + /** + * Returns all entries. + * + * @since 6.5.0 + * + * @return array 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(); +} diff --git a/wp-includes/l10n/class-wp-translations.php b/wp-includes/l10n/class-wp-translations.php new file mode 100644 index 0000000000..c3f5b16a55 --- /dev/null +++ b/wp-includes/l10n/class-wp-translations.php @@ -0,0 +1,157 @@ + $headers + * @property-read array $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; + } +} diff --git a/wp-includes/version.php b/wp-includes/version.php index 001c879c03..20687651cb 100644 --- a/wp-includes/version.php +++ b/wp-includes/version.php @@ -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. diff --git a/wp-settings.php b/wp-settings.php index d9da4172ee..28bcdded99 100644 --- a/wp-settings.php +++ b/wp-settings.php @@ -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' ) ) {