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' ) ) {