From 296960a8431e64bc358180cbfc6e9686b17daf83 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 24 May 2024 15:35:37 +0200 Subject: [PATCH 01/14] Parse `Accept-Language` header in `wp_load_translations_early` --- src/wp-includes/l10n.php | 35 +++++++++++++++++++++++++++++++++ src/wp-includes/load.php | 6 ++++++ tests/phpunit/tests/l10n.php | 38 ++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index 038b9160499ce..4e781711a321b 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -1988,3 +1988,38 @@ function wp_get_word_count_type() { return $wp_locale->get_word_count_type(); } + +/** + * Parses the `Accept-Language` header to get a list of locales. + * + * The locales are in the format WordPress expects, so "fr-CH" becomes "fr_CH" + * and "fr" becomes "fr_FR". "en" and wildcard values are discarded. + * The priority weighting is also discarded. + * + * @since 6.6.0 + * + * @return string[] Locales list. + */ +function get_locales_from_accept_language_header() { + $locales = array(); + + if ( ! empty( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) { + $matches = array(); + preg_match_all( '((?P[a-z-_A-Z]{2,5})([;q=]+?(?P0.\d+))?)', $_SERVER['HTTP_ACCEPT_LANGUAGE'], $matches ); + + foreach ( $matches['code'] as $code ) { + $locale = sanitize_locale_name( str_replace( '-', '_', $code ) ); + + if ( 'en' === $code ) { + continue; + } + + if ( 2 === strlen( $locale ) ) { + $locale = $locale . '_' . strtoupper( $locale ); + } + $locales[] = $locale; + } + } + + return $locales; +} diff --git a/src/wp-includes/load.php b/src/wp-includes/load.php index 6b743d459aa7b..7f23be4fd719c 100644 --- a/src/wp-includes/load.php +++ b/src/wp-includes/load.php @@ -1510,6 +1510,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'; @@ -1532,6 +1535,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/phpunit/tests/l10n.php b/tests/phpunit/tests/l10n.php index 7926a804da5fc..808ca7b6c53dc 100644 --- a/tests/phpunit/tests/l10n.php +++ b/tests/phpunit/tests/l10n.php @@ -619,4 +619,42 @@ 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 ) { + $_SERVER['HTTP_ACCEPT_LANGUAGE'] = $input; + + $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( + null, + array(), + ), + 'Empty header' => array( + false, + array(), + ), + 'Wildcard' => array( + '*', + array(), + ), + 'Multiple types, weighed' => array( + 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5', + array( 'fr_CH', 'fr_FR', 'de_DE' ), + ), + ); + } } From 8da9e2e4eba22c0fe52675b61e9e8828fa030a32 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 29 May 2024 10:17:16 +0200 Subject: [PATCH 02/14] Disallow arrays --- src/wp-includes/l10n.php | 2 +- tests/phpunit/tests/l10n.php | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index 4e781711a321b..32780d114a01e 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -2003,7 +2003,7 @@ function wp_get_word_count_type() { function get_locales_from_accept_language_header() { $locales = array(); - if ( ! empty( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) { + if ( ! empty( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) && is_string( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) { $matches = array(); preg_match_all( '((?P[a-z-_A-Z]{2,5})([;q=]+?(?P0.\d+))?)', $_SERVER['HTTP_ACCEPT_LANGUAGE'], $matches ); diff --git a/tests/phpunit/tests/l10n.php b/tests/phpunit/tests/l10n.php index 808ca7b6c53dc..e2ecf1e1146c1 100644 --- a/tests/phpunit/tests/l10n.php +++ b/tests/phpunit/tests/l10n.php @@ -647,6 +647,10 @@ public static function data_get_locales_from_accept_language_header() { false, array(), ), + 'Invalid type' => array( + array(), + array(), + ), 'Wildcard' => array( '*', array(), From f64bb7b52ad0b2e0f289035bdeabb91a4175e99d Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 29 May 2024 12:54:29 +0200 Subject: [PATCH 03/14] Check priority and transient --- src/wp-includes/l10n.php | 79 ++++++++++++++++++++++--- tests/phpunit/tests/l10n.php | 109 ++++++++++++++++++++++++++++++++--- 2 files changed, 171 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index 32780d114a01e..3d9d0e4f7e092 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -1992,9 +1992,8 @@ function wp_get_word_count_type() { /** * Parses the `Accept-Language` header to get a list of locales. * - * The locales are in the format WordPress expects, so "fr-CH" becomes "fr_CH" - * and "fr" becomes "fr_FR". "en" and wildcard values are discarded. - * The priority weighting is also discarded. + * The locales are returned in the format WordPress expects, + * so "fr-CH" becomes "fr_CH" and "fr" becomes "fr_FR". * * @since 6.6.0 * @@ -2005,19 +2004,81 @@ function get_locales_from_accept_language_header() { if ( ! empty( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) && is_string( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) { $matches = array(); - preg_match_all( '((?P[a-z-_A-Z]{2,5})([;q=]+?(?P0.\d+))?)', $_SERVER['HTTP_ACCEPT_LANGUAGE'], $matches ); + preg_match_all( '((?P[a-z-_A-Z]{2,5}|\*)([;q=]+?(?P1|0\.\d))?)', $_SERVER['HTTP_ACCEPT_LANGUAGE'], $matches ); + + if ( empty( $matches['code'] ) ) { + return $locales; + } + + $codes = $matches['code']; + + // An empty prio 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 ]; + } + ); + + // Get list of available translations without potentially deleting an expired transient. + $translations = wp_using_ext_object_cache() ? + wp_cache_get( 'available_translations', 'site-transient' ) : + 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; + } - foreach ( $matches['code'] as $code ) { $locale = sanitize_locale_name( str_replace( '-', '_', $code ) ); - if ( 'en' === $code ) { + if ( '' === $locale ) { continue; } - if ( 2 === strlen( $locale ) ) { - $locale = $locale . '_' . strtoupper( $locale ); + if ( $has_available_translations ) { + $found = array_keys( + array_filter( + $translations, + static function ( $translation ) use ( $locale ) { + return $locale === $translation['language'] || in_array( $locale, $translation['iso'], true ); + } + ) + ); + + array_push( $locales, ...$found ); + } else { + + // If English is accepted, then there is no point in adding any other locales after it. + if ( 'en' === $code ) { + break; + } + + $locales[] = $locale; + + // Fallback approximation, supporting cases like "el", but also "fr" -> "fr_FR", + if ( 2 === strlen( $locale ) ) { + $locales[] = $locale . '_' . strtoupper( $locale ); + } } - $locales[] = $locale; } } diff --git a/tests/phpunit/tests/l10n.php b/tests/phpunit/tests/l10n.php index e2ecf1e1146c1..efcfc7e60acff 100644 --- a/tests/phpunit/tests/l10n.php +++ b/tests/phpunit/tests/l10n.php @@ -627,9 +627,19 @@ public function test_length_of_comment_excerpt_should_be_counted_by_chars_in_Jap * * @dataProvider data_get_locales_from_accept_language_header */ - public function test_get_locales_from_accept_language_header( $input, $expected ) { + public function test_get_locales_from_accept_language_header( $input, $expected, $has_transient = false ) { $_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'] ); @@ -639,25 +649,108 @@ public function test_get_locales_from_accept_language_header( $input, $expected public static function data_get_locales_from_accept_language_header() { return array( - 'Missing header' => array( + 'Missing header' => array( null, array(), ), - 'Empty header' => array( + 'Empty header' => array( false, array(), ), - 'Invalid type' => array( + 'Invalid type' => array( array(), array(), ), - 'Wildcard' => array( + 'Wildcard' => array( '*', array(), ), - 'Multiple types, weighed' => array( - 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5', - array( 'fr_CH', 'fr_FR', 'de_DE' ), + 'Two-letter locales' => array( + 'de, fr, el, bn', + array( + 'de', + 'de_DE', + 'fr', + 'fr_FR', + 'el', + 'el_EL', + 'bn', + 'bn_BN', + ), + ), + 'Two-letter locales, with transient' => array( + 'de, fr, el, bn', + array( + 'de_CH', + 'de_CH_informal', + 'de_DE', + 'de_AT', + 'de_DE_formal', + 'fr_CA', + 'fr_FR', + 'fr_BE', + 'el', + 'bn_BD', + ), + true, + ), + 'Multiple types, weighed' => array( + 'fr-CH, fr;q=0.7, en;q=0.6, de;q=0.8, es-ES;q=0.5', + array( + 'fr_CH', + 'de', + 'de_DE', + 'fr', + 'fr_FR', + ), + ), + 'Multiple types, weighed, with transient' => array( + 'fr-CH, fr;q=0.7, en;q=0.6, de;q=0.8, es-ES;q=0.5', + array( + // Absent because WordPress is not fully translated into it. + // 'fr_CH', + 'de_CH', + 'de_CH_informal', + 'de_DE', + 'de_AT', + 'de_DE_formal', + 'fr_CA', + 'fr_FR', + 'fr_BE', + 'en_ZA', + 'en_CA', + 'en_AU', + 'en_NZ', + 'en_GB', + 'es_ES', + ), + true, + ), + 'Multiple types, weighed, with wildcard' => array( + 'fr-CH, fr;q=0.7, *;q=0.6, de;q=0.8, es-ES;q=0.5', + array( + 'fr_CH', + 'de', + 'de_DE', + 'fr', + 'fr_FR', + ), + ), + 'Multiple types, weighed, with wildcard, with transient' => array( + 'fr-CH, fr;q=0.7, *;q=0.6, de;q=0.8, es-ES;q=0.5', + array( + // Absent because WordPress is not fully translated into it. + // 'fr_CH', + 'de_CH', + 'de_CH_informal', + 'de_DE', + 'de_AT', + 'de_DE_formal', + 'fr_CA', + 'fr_FR', + 'fr_BE', + ), + true, ), ); } From 3736aab30d94f2bbeebde02cf6bcfaa3b67f9652 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 29 May 2024 13:15:10 +0200 Subject: [PATCH 04/14] Wrap in `empty` --- src/wp-includes/l10n.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index 3d9d0e4f7e092..1e70751425873 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -2064,7 +2064,9 @@ static function ( $translation ) use ( $locale ) { ) ); - array_push( $locales, ...$found ); + if ( ! empty( $found ) ) { + array_push( $locales, ...$found ); + } } else { // If English is accepted, then there is no point in adding any other locales after it. From a16e55dbe4748f30ca9b2867d4f926d6f051d6df Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 29 May 2024 17:14:08 +0200 Subject: [PATCH 05/14] Ensure stable sort, always drop `en` --- src/wp-includes/l10n.php | 11 ++++++----- tests/phpunit/tests/l10n.php | 18 ++++++------------ 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index 1e70751425873..fa3873c9f0ec6 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -2054,6 +2054,11 @@ static function ( $a, $b ) use ( $codes, $prios ) { 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( @@ -2063,17 +2068,13 @@ static function ( $translation ) use ( $locale ) { } ) ); + sort( $found ); if ( ! empty( $found ) ) { array_push( $locales, ...$found ); } } else { - // If English is accepted, then there is no point in adding any other locales after it. - if ( 'en' === $code ) { - break; - } - $locales[] = $locale; // Fallback approximation, supporting cases like "el", but also "fr" -> "fr_FR", diff --git a/tests/phpunit/tests/l10n.php b/tests/phpunit/tests/l10n.php index efcfc7e60acff..31e54dcddfd6f 100644 --- a/tests/phpunit/tests/l10n.php +++ b/tests/phpunit/tests/l10n.php @@ -681,14 +681,14 @@ public static function data_get_locales_from_accept_language_header() { 'Two-letter locales, with transient' => array( 'de, fr, el, bn', array( + 'de_AT', 'de_CH', 'de_CH_informal', 'de_DE', - 'de_AT', 'de_DE_formal', + 'fr_BE', 'fr_CA', 'fr_FR', - 'fr_BE', 'el', 'bn_BD', ), @@ -709,20 +709,14 @@ public static function data_get_locales_from_accept_language_header() { array( // Absent because WordPress is not fully translated into it. // 'fr_CH', + 'de_AT', 'de_CH', 'de_CH_informal', 'de_DE', - 'de_AT', 'de_DE_formal', + 'fr_BE', 'fr_CA', 'fr_FR', - 'fr_BE', - 'en_ZA', - 'en_CA', - 'en_AU', - 'en_NZ', - 'en_GB', - 'es_ES', ), true, ), @@ -741,14 +735,14 @@ public static function data_get_locales_from_accept_language_header() { array( // Absent because WordPress is not fully translated into it. // 'fr_CH', + 'de_AT', 'de_CH', 'de_CH_informal', 'de_DE', - 'de_AT', 'de_DE_formal', + 'fr_BE', 'fr_CA', 'fr_FR', - 'fr_BE', ), true, ), From d22e5163b23f38c4d9b5d2a298109d48b141894d Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 29 May 2024 17:16:41 +0200 Subject: [PATCH 06/14] Add ky / kir test case --- tests/phpunit/tests/l10n.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/l10n.php b/tests/phpunit/tests/l10n.php index 31e54dcddfd6f..cc591ae9298f4 100644 --- a/tests/phpunit/tests/l10n.php +++ b/tests/phpunit/tests/l10n.php @@ -666,7 +666,7 @@ public static function data_get_locales_from_accept_language_header() { array(), ), 'Two-letter locales' => array( - 'de, fr, el, bn', + 'de, fr, el, bn, ky', array( 'de', 'de_DE', @@ -676,10 +676,12 @@ public static function data_get_locales_from_accept_language_header() { 'el_EL', 'bn', 'bn_BN', + 'ky', + 'ky_KY', ), ), 'Two-letter locales, with transient' => array( - 'de, fr, el, bn', + 'de, fr, el, bn, ky', array( 'de_AT', 'de_CH', @@ -691,6 +693,7 @@ public static function data_get_locales_from_accept_language_header() { 'fr_FR', 'el', 'bn_BD', + 'kir', ), true, ), From 858e9cd0bd5042400d9d25ed18ff13fcb6eec525 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 29 May 2024 17:17:06 +0200 Subject: [PATCH 07/14] Adjust comment --- src/wp-includes/l10n.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index fa3873c9f0ec6..fbb0d3cc28e15 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -2035,7 +2035,7 @@ static function ( $a, $b ) use ( $codes, $prios ) { } ); - // Get list of available translations without potentially deleting an expired transient. + // Get list of available translations without potentially deleting an expired transient and causing an HTTP request. $translations = wp_using_ext_object_cache() ? wp_cache_get( 'available_translations', 'site-transient' ) : get_site_option( '_site_transient_available_translations' ); From fee88843b4f93a206c4b8573cb01d3afc003ed52 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 17 Jun 2024 14:48:50 +0200 Subject: [PATCH 08/14] Change version --- src/wp-includes/l10n.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index fbb0d3cc28e15..afc2bb80f9a05 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -1995,7 +1995,7 @@ function wp_get_word_count_type() { * The locales are returned in the format WordPress expects, * so "fr-CH" becomes "fr_CH" and "fr" becomes "fr_FR". * - * @since 6.6.0 + * @since 6.7.0 * * @return string[] Locales list. */ From 601bfaa641ac054c8e1d53ad3f4cbb6a4e9f1286 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 18 Jun 2024 09:16:50 +0200 Subject: [PATCH 09/14] Add more test cases --- src/wp-includes/l10n.php | 7 ++++-- tests/phpunit/tests/l10n.php | 49 ++++++++++++++++++++++++++++-------- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index afc2bb80f9a05..7efcca6c5ef86 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -1995,6 +1995,8 @@ function wp_get_word_count_type() { * 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 * * @return string[] Locales list. @@ -2004,7 +2006,8 @@ function get_locales_from_accept_language_header() { if ( ! empty( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) && is_string( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) { $matches = array(); - preg_match_all( '((?P[a-z-_A-Z]{2,5}|\*)([;q=]+?(?P1|0\.\d))?)', $_SERVER['HTTP_ACCEPT_LANGUAGE'], $matches ); + // 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; @@ -2012,7 +2015,7 @@ function get_locales_from_accept_language_header() { $codes = $matches['code']; - // An empty prio defaults to 1. + // An empty priority defaults to 1. $prios = array_map( static function ( $value ) { if ( '' === $value ) { diff --git a/tests/phpunit/tests/l10n.php b/tests/phpunit/tests/l10n.php index cc591ae9298f4..1a0e4e232ecb8 100644 --- a/tests/phpunit/tests/l10n.php +++ b/tests/phpunit/tests/l10n.php @@ -628,7 +628,11 @@ public function test_length_of_comment_excerpt_should_be_counted_by_chars_in_Jap * @dataProvider data_get_locales_from_accept_language_header */ public function test_get_locales_from_accept_language_header( $input, $expected, $has_transient = false ) { - $_SERVER['HTTP_ACCEPT_LANGUAGE'] = $input; + 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. @@ -650,6 +654,10 @@ public function test_get_locales_from_accept_language_header( $input, $expected, 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(), ), @@ -697,10 +705,31 @@ public static function data_get_locales_from_accept_language_header() { ), 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-CH, fr;q=0.7, en;q=0.6, de;q=0.8, es-ES;q=0.5', + 'fr-BE, fr;q=0.7, en;q=0.6, de;q=0.8, es-ES;q=0.5', array( - 'fr_CH', + 'fr_BE', 'de', 'de_DE', 'fr', @@ -708,10 +737,9 @@ public static function data_get_locales_from_accept_language_header() { ), ), 'Multiple types, weighed, with transient' => array( - 'fr-CH, fr;q=0.7, en;q=0.6, de;q=0.8, es-ES;q=0.5', + 'fr-BE, fr;q=0.7, en;q=0.6, de;q=0.8, es-ES;q=0.5', array( - // Absent because WordPress is not fully translated into it. - // 'fr_CH', + 'fr_BE', 'de_AT', 'de_CH', 'de_CH_informal', @@ -724,9 +752,9 @@ public static function data_get_locales_from_accept_language_header() { true, ), 'Multiple types, weighed, with wildcard' => array( - 'fr-CH, fr;q=0.7, *;q=0.6, de;q=0.8, es-ES;q=0.5', + 'fr-BE, fr;q=0.7, *;q=0.6, de;q=0.8, es-ES;q=0.5', array( - 'fr_CH', + 'fr_BE', 'de', 'de_DE', 'fr', @@ -734,10 +762,9 @@ public static function data_get_locales_from_accept_language_header() { ), ), 'Multiple types, weighed, with wildcard, with transient' => array( - 'fr-CH, fr;q=0.7, *;q=0.6, de;q=0.8, es-ES;q=0.5', + 'fr-BE, fr;q=0.7, *;q=0.6, de;q=0.8, es-ES;q=0.5', array( - // Absent because WordPress is not fully translated into it. - // 'fr_CH', + 'fr_BE', 'de_AT', 'de_CH', 'de_CH_informal', From ffe58bd0df6ab052b7d9a54c7b53842958c5f497 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 18 Jun 2024 10:27:24 +0200 Subject: [PATCH 10/14] Add end-to-end test --- .../workflows/reusable-end-to-end-tests.yml | 7 +++ tests/e2e/specs/fatal-error-handler.test.js | 44 ++++++++++++++----- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/.github/workflows/reusable-end-to-end-tests.yml b/.github/workflows/reusable-end-to-end-tests.yml index 7aa23fbb1aa9e..768f31ef97d00 100644 --- a/.github/workflows/reusable-end-to-end-tests.yml +++ b/.github/workflows/reusable-end-to-end-tests.yml @@ -45,6 +45,7 @@ jobs: # - Logs Docker debug information (about both the Docker installation within the runner and the WordPress container). # - Install WordPress within the Docker container. # - Install Gutenberg. + # - Install additional languages. # - Run the E2E tests. # - Uploads screenshots and HTML snapshots as an artifact. # - Ensures version-controlled files are not modified or deleted. @@ -114,6 +115,12 @@ jobs: if: ${{ inputs.install-gutenberg }} run: npm run env:cli -- plugin install gutenberg --path=/var/www/${{ env.LOCAL_DIR }} + - name: Install additional languages + run: | + npm run env:cli -- language core install de_DE --path=/var/www/${{ env.LOCAL_DIR }} + npm run env:cli -- language plugin install de_DE --all --path=/var/www/${{ env.LOCAL_DIR }} + npm run env:cli -- language theme install de_DE --all --path=/var/www/${{ env.LOCAL_DIR }} + - name: Run E2E tests run: npm run test:e2e diff --git a/tests/e2e/specs/fatal-error-handler.test.js b/tests/e2e/specs/fatal-error-handler.test.js index 1b5522358ebb9..ffdaaa1e95281 100644 --- a/tests/e2e/specs/fatal-error-handler.test.js +++ b/tests/e2e/specs/fatal-error-handler.test.js @@ -30,17 +30,37 @@ test.describe( 'Fatal error handler', () => { unlinkSync( muPluginFile ); } ); - test( 'should display fatal error notice', async ( { admin, page } ) => { - await admin.visitAdminPage( '/' ); - - await expect( - page.getByText( /Fatal error:/ ), - 'should display PHP error message' - ).toBeVisible(); - - await expect( - page.getByText( /There has been a critical error on this website/ ), - 'should display WordPress fatal error handler message' - ).toBeVisible(); + test.describe( 'Default (en_US)', () => { + test( 'should display fatal error notice', async ( { admin, page } ) => { + await admin.visitAdminPage( '/' ); + + await expect( + page.getByText( /Fatal error:/ ), + 'should display PHP error message' + ).toBeVisible(); + + await expect( + page.getByText( /There has been a critical error on this website/ ), + 'should display WordPress fatal error handler message' + ).toBeVisible(); + } ); + } ); + + test.describe( 'Localized (de_DE)', () => { + test.use( { locale: 'de-DE' } ); // Sets the Accept-Language header. + + test( 'should display fatal error notice', async ( { admin, page } ) => { + await admin.visitAdminPage( '/' ); + + await expect( + page.getByText( /Fatal error:/ ), + 'should display PHP error message' + ).toBeVisible(); + + await expect( + page.getByText( /Es gab einen kritischen Fehler auf deiner Website/ ), + 'should display WordPress fatal error handler message' + ).toBeVisible(); + } ); } ); } ); From 26056249dad47ebdb5f88450782f8774b40c2805 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 18 Jun 2024 11:03:07 +0200 Subject: [PATCH 11/14] Test maintenance mode instead --- tests/e2e/specs/fatal-error-handler.test.js | 44 ++++++--------------- tests/e2e/specs/maintenance-mode.test.js | 23 ++++++++--- 2 files changed, 30 insertions(+), 37 deletions(-) diff --git a/tests/e2e/specs/fatal-error-handler.test.js b/tests/e2e/specs/fatal-error-handler.test.js index ffdaaa1e95281..1b5522358ebb9 100644 --- a/tests/e2e/specs/fatal-error-handler.test.js +++ b/tests/e2e/specs/fatal-error-handler.test.js @@ -30,37 +30,17 @@ test.describe( 'Fatal error handler', () => { unlinkSync( muPluginFile ); } ); - test.describe( 'Default (en_US)', () => { - test( 'should display fatal error notice', async ( { admin, page } ) => { - await admin.visitAdminPage( '/' ); - - await expect( - page.getByText( /Fatal error:/ ), - 'should display PHP error message' - ).toBeVisible(); - - await expect( - page.getByText( /There has been a critical error on this website/ ), - 'should display WordPress fatal error handler message' - ).toBeVisible(); - } ); - } ); - - test.describe( 'Localized (de_DE)', () => { - test.use( { locale: 'de-DE' } ); // Sets the Accept-Language header. - - test( 'should display fatal error notice', async ( { admin, page } ) => { - await admin.visitAdminPage( '/' ); - - await expect( - page.getByText( /Fatal error:/ ), - 'should display PHP error message' - ).toBeVisible(); - - await expect( - page.getByText( /Es gab einen kritischen Fehler auf deiner Website/ ), - 'should display WordPress fatal error handler message' - ).toBeVisible(); - } ); + test( 'should display fatal error notice', async ( { admin, page } ) => { + await admin.visitAdminPage( '/' ); + + await expect( + page.getByText( /Fatal error:/ ), + 'should display PHP error message' + ).toBeVisible(); + + await expect( + page.getByText( /There has been a critical error on this website/ ), + 'should display WordPress fatal error handler message' + ).toBeVisible(); } ); } ); 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(); + } ); } ); } ); From 00bd7aa37993351da54845ecec56a1a3abf121d0 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 18 Jun 2024 11:03:14 +0200 Subject: [PATCH 12/14] Add hardening --- src/wp-includes/l10n.php | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index 7efcca6c5ef86..28c79eb07e2a0 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -1998,10 +1998,15 @@ function wp_get_word_count_type() { * @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; + $locales = array(); if ( ! empty( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) && is_string( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) { @@ -2038,10 +2043,17 @@ static function ( $a, $b ) use ( $codes, $prios ) { } ); - // Get list of available translations without potentially deleting an expired transient and causing an HTTP request. - $translations = wp_using_ext_object_cache() ? - wp_cache_get( 'available_translations', 'site-transient' ) : - get_site_option( '_site_transient_available_translations' ); + $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_installing() ) { + $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 ); From 07c164c31138307bb478df04b8d3c2abe6ca876e Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 18 Jun 2024 12:04:41 +0200 Subject: [PATCH 13/14] Start object cache if needed --- src/wp-includes/l10n.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index 28c79eb07e2a0..6ec5f810cb1dd 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -2050,6 +2050,7 @@ static function ( $a, $b ) use ( $codes, $prios ) { * Only works if either the object cache or the database are already available. */ if ( wp_using_ext_object_cache() || wp_installing() ) { + wp_start_object_cache(); $translations = wp_cache_get( 'available_translations', 'site-transient' ); } elseif ( isset( $wpdb ) ) { $translations = get_site_option( '_site_transient_available_translations' ); From b233b05cf78001bdb12c8a051e3b892eb50323c4 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 18 Jun 2024 19:14:31 +0200 Subject: [PATCH 14/14] Apply suggestions from code review --- src/wp-includes/l10n.php | 144 ++++++++++++++++++++------------------- 1 file changed, 73 insertions(+), 71 deletions(-) diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index 6ec5f810cb1dd..c781fa5803e27 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -2007,96 +2007,98 @@ function wp_get_word_count_type() { 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(); - if ( ! empty( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) && is_string( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) { - $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 ); + $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; - } + if ( empty( $matches['code'] ) ) { + return $locales; + } - $codes = $matches['code']; + $codes = $matches['code']; - // An empty priority defaults to 1. - $prios = array_map( - static function ( $value ) { - if ( '' === $value ) { - return 1.0; - } + // An empty priority defaults to 1. + $prios = array_map( + static function ( $value ) { + if ( '' === $value ) { + return 1.0; + } - return (float) $value; - }, - $matches['prio'] - ); + 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 ); + // 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 ]; - } - ); + return $prios[ $index_b ] <=> $prios[ $index_a ]; + } + ); - $translations = array(); + $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_installing() ) { - wp_start_object_cache(); - $translations = wp_cache_get( 'available_translations', 'site-transient' ); - } elseif ( isset( $wpdb ) ) { - $translations = get_site_option( '_site_transient_available_translations' ); + /* + * 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; } - $has_available_translations = is_array( $translations ) && ! empty( $translations ); + $locale = sanitize_locale_name( str_replace( '-', '_', $code ) ); - foreach ( $codes as $code ) { - if ( '*' === $code ) { - // Ignore anything after the wildcard, as we can then just default to en_US. - break; - } + if ( '' === $locale ) { + continue; + } - $locale = sanitize_locale_name( str_replace( '-', '_', $code ) ); + // If English is accepted, then there is no point in adding any other locales after it. + if ( 'en' === $locale ) { + break; + } - if ( '' === $locale ) { - continue; - } + 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 English is accepted, then there is no point in adding any other locales after it. - if ( 'en' === $locale ) { - break; + if ( ! empty( $found ) ) { + array_push( $locales, ...$found ); } + } else { - if ( $has_available_translations ) { - $found = array_keys( - array_filter( - $translations, - static function ( $translation ) use ( $locale ) { - return $locale === $translation['language'] || in_array( $locale, $translation['iso'], true ); - } - ) - ); - sort( $found ); - - if ( ! empty( $found ) ) { - array_push( $locales, ...$found ); - } - } else { - - $locales[] = $locale; + $locales[] = $locale; - // Fallback approximation, supporting cases like "el", but also "fr" -> "fr_FR", - if ( 2 === strlen( $locale ) ) { - $locales[] = $locale . '_' . strtoupper( $locale ); - } + // Fallback approximation, supporting cases like "el", but also "fr" -> "fr_FR", + if ( 2 === strlen( $locale ) ) { + $locales[] = $locale . '_' . strtoupper( $locale ); } } }