diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index 40a5e14ade879..6f254de92f6b5 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -2019,3 +2019,120 @@ function wp_get_word_count_type() { function has_translation( string $singular, string $textdomain = 'default', ?string $locale = null ): bool { return WP_Translation_Controller::get_instance()->has_translation( $singular, $textdomain, $locale ); } + +/** + * Parses the `Accept-Language` header to get a list of locales. + * + * The locales are returned in the format WordPress expects, + * so "fr-CH" becomes "fr_CH" and "fr" becomes "fr_FR". + * + * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language + * + * @since 6.7.0 + * @access private + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @return string[] Locales list. + */ +function get_locales_from_accept_language_header() { + global $wpdb; + + if ( empty( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) || ! is_string( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) { + return array(); + } + + $locales = array(); + + $matches = array(); + // Parses a header value such as "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5". + preg_match_all( '((?P[a-z-_A-Z]+|\*)([;q=]+?(?P1|0\.\d))?)', $_SERVER['HTTP_ACCEPT_LANGUAGE'], $matches ); + + if ( empty( $matches['code'] ) ) { + return $locales; + } + + $codes = $matches['code']; + + // An empty priority defaults to 1. + $prios = array_map( + static function ( $value ) { + if ( '' === $value ) { + return 1.0; + } + + return (float) $value; + }, + $matches['prio'] + ); + + // Sort codes by priority. + usort( + $codes, + static function ( $a, $b ) use ( $codes, $prios ) { + $index_a = array_search( $a, $codes, true ); + $index_b = array_search( $b, $codes, true ); + + return $prios[ $index_b ] <=> $prios[ $index_a ]; + } + ); + + $translations = array(); + + /* + * Get list of available translations without potentially deleting an expired transient and causing an HTTP request. + * Only works if either the object cache or the database are already available. + */ + if ( wp_using_ext_object_cache() ) { + wp_start_object_cache(); + $translations = wp_cache_get( 'available_translations', 'site-transient' ); + } elseif ( isset( $wpdb ) ) { + $translations = get_site_option( '_site_transient_available_translations' ); + } + + $has_available_translations = is_array( $translations ) && ! empty( $translations ); + + foreach ( $codes as $code ) { + if ( '*' === $code ) { + // Ignore anything after the wildcard, as we can then just default to en_US. + break; + } + + $locale = sanitize_locale_name( str_replace( '-', '_', $code ) ); + + if ( '' === $locale ) { + continue; + } + + // If English is accepted, then there is no point in adding any other locales after it. + if ( 'en' === $locale ) { + break; + } + + if ( $has_available_translations ) { + $found = array_keys( + array_filter( + $translations, + static function ( $translation ) use ( $locale, $code ) { + return $locale === $translation['language'] || in_array( $code, $translation['iso'], true ); + } + ) + ); + sort( $found ); + + if ( ! empty( $found ) ) { + array_push( $locales, ...$found ); + } + } else { + + $locales[] = $locale; + + // Fallback approximation, supporting cases like "el", but also "fr" -> "fr_FR", + if ( 2 === strlen( $locale ) ) { + $locales[] = $locale . '_' . strtoupper( $locale ); + } + } + } + + return $locales; +} diff --git a/src/wp-includes/load.php b/src/wp-includes/load.php index 095affd67747a..ec8f270b60119 100644 --- a/src/wp-includes/load.php +++ b/src/wp-includes/load.php @@ -1550,6 +1550,9 @@ function wp_load_translations_early() { require_once ABSPATH . WPINC . '/class-wp-locale.php'; require_once ABSPATH . WPINC . '/class-wp-locale-switcher.php'; + // For sanitize_locale_name(). + require_once ABSPATH . WPINC . '/formatting.php'; + // General libraries. require_once ABSPATH . WPINC . '/plugin.php'; @@ -1572,6 +1575,9 @@ function wp_load_translations_early() { $locales[] = $wp_local_package; } + // Try the browser's locale + $locales = array_merge( $locales, get_locales_from_accept_language_header() ); + if ( ! $locales ) { break; } diff --git a/tests/e2e/specs/maintenance-mode.test.js b/tests/e2e/specs/maintenance-mode.test.js index df9d409a07895..04982031808be 100644 --- a/tests/e2e/specs/maintenance-mode.test.js +++ b/tests/e2e/specs/maintenance-mode.test.js @@ -24,10 +24,23 @@ test.describe( 'Maintenance mode', () => { unlinkSync( maintenanceLockFile ); } ); - test( 'should display maintenance mode page', async ( { page } ) => { - await page.goto( '/' ); - await expect( - page.getByText( /Briefly unavailable for scheduled maintenance\. Check back in a minute\./ ) - ).toBeVisible(); + test.describe( 'Default (en_US)', () => { + test( 'should display maintenance mode page', async ( { page } ) => { + await page.goto( '/' ); + await expect( + page.getByText( /Briefly unavailable for scheduled maintenance\. Check back in a minute\./ ) + ).toBeVisible(); + } ); + } ); + + test.describe( 'Localized (de_DE)', () => { + test.use( { locale: 'de-DE' } ); // Sets the Accept-Language header. + + test( 'should display maintenance mode page', async ( { page } ) => { + await page.goto( '/' ); + await expect( + page.getByText( /Wegen Wartungsarbeiten ist diese Website kurzzeitig nicht verfügbar/ ) + ).toBeVisible(); + } ); } ); } ); diff --git a/tests/phpunit/tests/l10n.php b/tests/phpunit/tests/l10n.php index 2f7992c34069f..32a0e3bd0a214 100644 --- a/tests/phpunit/tests/l10n.php +++ b/tests/phpunit/tests/l10n.php @@ -630,4 +630,163 @@ public function test_length_of_comment_excerpt_should_be_counted_by_chars_in_Jap $this->assertSame( $expect, $comment_excerpt ); } + + /** + * @ticket 30049 + * + * @covers ::get_locales_from_accept_language_header + * + * @dataProvider data_get_locales_from_accept_language_header + */ + public function test_get_locales_from_accept_language_header( $input, $expected, $has_transient = false ) { + if ( 'missing' === $input ) { + unset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ); + } else { + $_SERVER['HTTP_ACCEPT_LANGUAGE'] = $input; + } + + if ( $has_transient ) { + // Fetches available translations and stores them in a transient. + require_once ABSPATH . 'wp-admin/includes/translation-install.php'; + + wp_get_available_translations(); + } else { + delete_site_transient( 'available_translations' ); + wp_cache_delete( 'available_translations', 'site-transient' ); + } + + $actual = get_locales_from_accept_language_header(); + + unset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ); + + $this->assertSame( $expected, $actual, 'Parsed locales list does not expect actual list' ); + } + + public static function data_get_locales_from_accept_language_header() { + return array( + 'Missing header' => array( + 'missing', // Will be handled specially in the test. + array(), + ), + 'Null header' => array( + null, + array(), + ), + 'Empty header' => array( + false, + array(), + ), + 'Invalid type' => array( + array(), + array(), + ), + 'Wildcard' => array( + '*', + array(), + ), + 'Two-letter locales' => array( + 'de, fr, el, bn, ky', + array( + 'de', + 'de_DE', + 'fr', + 'fr_FR', + 'el', + 'el_EL', + 'bn', + 'bn_BN', + 'ky', + 'ky_KY', + ), + ), + 'Two-letter locales, with transient' => array( + 'de, fr, el, bn, ky', + array( + 'de_AT', + 'de_CH', + 'de_CH_informal', + 'de_DE', + 'de_DE_formal', + 'fr_BE', + 'fr_CA', + 'fr_FR', + 'el', + 'bn_BD', + 'kir', + ), + true, + ), + // en-GB-oxendict is supported by Chrome, ca-valencia by Firefox. + 'Longer variants' => array( + 'en-GB-oxendict, de-DE, ca-valencia, ca', + array( + 'en_GB_oxendict', + 'de_DE', + 'ca_valencia', + 'ca', + 'ca_CA', + ), + ), + 'Longer variants, with transient' => array( + 'en-GB-oxendict, de-DE, ca-valencia, ca', + array( + 'de_DE', + // Absent because there is no language pack for ca_valencia yet, see https://make.wordpress.org/polyglots/teams/. + //'ca_valencia', + 'ca', + ), + true, + ), + 'Multiple types, weighed' => array( + 'fr-BE, fr;q=0.7, en;q=0.6, de;q=0.8, es-ES;q=0.5', + array( + 'fr_BE', + 'de', + 'de_DE', + 'fr', + 'fr_FR', + ), + ), + 'Multiple types, weighed, with transient' => array( + 'fr-BE, fr;q=0.7, en;q=0.6, de;q=0.8, es-ES;q=0.5', + array( + 'fr_BE', + 'de_AT', + 'de_CH', + 'de_CH_informal', + 'de_DE', + 'de_DE_formal', + 'fr_BE', + 'fr_CA', + 'fr_FR', + ), + true, + ), + 'Multiple types, weighed, with wildcard' => array( + 'fr-BE, fr;q=0.7, *;q=0.6, de;q=0.8, es-ES;q=0.5', + array( + 'fr_BE', + 'de', + 'de_DE', + 'fr', + 'fr_FR', + ), + ), + 'Multiple types, weighed, with wildcard, with transient' => array( + 'fr-BE, fr;q=0.7, *;q=0.6, de;q=0.8, es-ES;q=0.5', + array( + 'fr_BE', + 'de_AT', + 'de_CH', + 'de_CH_informal', + 'de_DE', + 'de_DE_formal', + 'fr_BE', + 'fr_CA', + 'fr_FR', + ), + true, + ), + ); + } }