Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
296960a
Parse `Accept-Language` header in `wp_load_translations_early`
swissspidy May 24, 2024
862ea0d
Merge branch 'trunk' into fix/30049
swissspidy May 29, 2024
8da9e2e
Disallow arrays
swissspidy May 29, 2024
ee89cb8
Merge branch 'trunk' into fix/30049
swissspidy May 29, 2024
f64bb7b
Check priority and transient
swissspidy May 29, 2024
3736aab
Wrap in `empty`
swissspidy May 29, 2024
a16e55d
Ensure stable sort, always drop `en`
swissspidy May 29, 2024
d22e516
Add ky / kir test case
swissspidy May 29, 2024
8515209
Merge branch 'trunk' into fix/30049
swissspidy May 29, 2024
858e9cd
Adjust comment
swissspidy May 29, 2024
e0c08ef
Merge branch 'trunk' into fix/30049
swissspidy Jun 3, 2024
a816dc8
Merge branch 'trunk' into fix/30049
swissspidy Jun 10, 2024
fee8884
Change version
swissspidy Jun 17, 2024
601bfaa
Add more test cases
swissspidy Jun 18, 2024
6a33abb
Merge branch 'trunk' into fix/30049
swissspidy Jun 18, 2024
d9457e8
Merge branch 'trunk' into fix/30049
swissspidy Jun 18, 2024
ffe58bd
Add end-to-end test
swissspidy Jun 18, 2024
2605624
Test maintenance mode instead
swissspidy Jun 18, 2024
00bd7aa
Add hardening
swissspidy Jun 18, 2024
07c164c
Start object cache if needed
swissspidy Jun 18, 2024
3d42d79
Merge branch 'trunk' into fix/30049
swissspidy Jun 18, 2024
b233b05
Apply suggestions from code review
swissspidy Jun 18, 2024
4a9cddf
Merge branch 'trunk' into fix/30049
swissspidy Jun 18, 2024
81f6961
Merge branch 'trunk' into fix/30049
swissspidy Jun 25, 2024
3f34e28
Merge branch 'trunk' into fix/30049
swissspidy Aug 22, 2024
fff8dc1
Merge branch 'trunk' into fix/30049
swissspidy Sep 3, 2024
e52d38a
Merge branch 'trunk' into fix/30049
swissspidy Sep 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions src/wp-includes/l10n.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<code>[a-z-_A-Z]+|\*)([;q=]+?(?P<prio>1|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;
}
6 changes: 6 additions & 0 deletions src/wp-includes/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
}
Expand Down
23 changes: 18 additions & 5 deletions tests/e2e/specs/maintenance-mode.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
} );
} );
} );
159 changes: 159 additions & 0 deletions tests/phpunit/tests/l10n.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
);
}
}
Loading