Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 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
39ebe00
Merge branch 'trunk' into fix/30049
swissspidy Jun 29, 2026
6d806bd
Apply suggestions from code review
swissspidy Jun 29, 2026
fa79c39
Merge branch 'trunk' into fix/30049
swissspidy Aug 21, 2026
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
126 changes: 126 additions & 0 deletions src/wp-includes/l10n.php
Original file line number Diff line number Diff line change
Expand Up @@ -2082,3 +2082,129 @@ 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-Za-z0-9_-]+|\*)(?:\s*;\s*q\s*=\s*(?P<prio>1(?:\.0{0,3})?|0(?:\.\d{1,3})?))?/i', $_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 (stable), keeping duplicates.
$weighted_codes = array();
foreach ( $codes as $i => $code ) {
$weighted_codes[] = array(
'code' => $code,
'prio' => $prios[ $i ] ?? 1.0,
'index' => $i,
);
}

usort(
$weighted_codes,
static function ( $a, $b ) {
$cmp = $b['prio'] <=> $a['prio'];
return 0 !== $cmp ? $cmp : ( $a['index'] <=> $b['index'] );
}
);

$codes = array_column( $weighted_codes, 'code' );

$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() );
Comment thread
ocean90 marked this conversation as resolved.

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 @@
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();

Check failure on line 43 in tests/e2e/specs/maintenance-mode.test.js

View workflow job for this annotation

GitHub Actions / E2E Tests / SCRIPT_DEBUG enabled

[chromium] › tests/e2e/specs/maintenance-mode.test.js:39:7 › Maintenance mode › Localized (de_DE) › should display maintenance mode page

2) [chromium] › tests/e2e/specs/maintenance-mode.test.js:39:7 › Maintenance mode › Localized (de_DE) › should display maintenance mode page Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: getByText(/Wegen Wartungsarbeiten ist diese Website kurzzeitig nicht verfügbar/) Expected: visible Timeout: 5000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 5000ms - waiting for getByText(/Wegen Wartungsarbeiten ist diese Website kurzzeitig nicht verfügbar/) 41 | await expect( 42 | page.getByText( /Wegen Wartungsarbeiten ist diese Website kurzzeitig nicht verfügbar/ ) > 43 | ).toBeVisible(); | ^ 44 | } ); 45 | } ); 46 | } ); at /home/runner/work/wordpress-develop/wordpress-develop/tests/e2e/specs/maintenance-mode.test.js:43:6

Check failure on line 43 in tests/e2e/specs/maintenance-mode.test.js

View workflow job for this annotation

GitHub Actions / E2E Tests / SCRIPT_DEBUG enabled

[chromium] › tests/e2e/specs/maintenance-mode.test.js:39:7 › Maintenance mode › Localized (de_DE) › should display maintenance mode page

2) [chromium] › tests/e2e/specs/maintenance-mode.test.js:39:7 › Maintenance mode › Localized (de_DE) › should display maintenance mode page Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: getByText(/Wegen Wartungsarbeiten ist diese Website kurzzeitig nicht verfügbar/) Expected: visible Timeout: 5000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 5000ms - waiting for getByText(/Wegen Wartungsarbeiten ist diese Website kurzzeitig nicht verfügbar/) 41 | await expect( 42 | page.getByText( /Wegen Wartungsarbeiten ist diese Website kurzzeitig nicht verfügbar/ ) > 43 | ).toBeVisible(); | ^ 44 | } ); 45 | } ); 46 | } ); at /home/runner/work/wordpress-develop/wordpress-develop/tests/e2e/specs/maintenance-mode.test.js:43:6

Check failure on line 43 in tests/e2e/specs/maintenance-mode.test.js

View workflow job for this annotation

GitHub Actions / E2E Tests / SCRIPT_DEBUG disabled

[chromium] › tests/e2e/specs/maintenance-mode.test.js:39:7 › Maintenance mode › Localized (de_DE) › should display maintenance mode page

2) [chromium] › tests/e2e/specs/maintenance-mode.test.js:39:7 › Maintenance mode › Localized (de_DE) › should display maintenance mode page Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: getByText(/Wegen Wartungsarbeiten ist diese Website kurzzeitig nicht verfügbar/) Expected: visible Timeout: 5000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 5000ms - waiting for getByText(/Wegen Wartungsarbeiten ist diese Website kurzzeitig nicht verfügbar/) 41 | await expect( 42 | page.getByText( /Wegen Wartungsarbeiten ist diese Website kurzzeitig nicht verfügbar/ ) > 43 | ).toBeVisible(); | ^ 44 | } ); 45 | } ); 46 | } ); at /home/runner/work/wordpress-develop/wordpress-develop/tests/e2e/specs/maintenance-mode.test.js:43:6

Check failure on line 43 in tests/e2e/specs/maintenance-mode.test.js

View workflow job for this annotation

GitHub Actions / E2E Tests / SCRIPT_DEBUG disabled

[chromium] › tests/e2e/specs/maintenance-mode.test.js:39:7 › Maintenance mode › Localized (de_DE) › should display maintenance mode page

2) [chromium] › tests/e2e/specs/maintenance-mode.test.js:39:7 › Maintenance mode › Localized (de_DE) › should display maintenance mode page Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: getByText(/Wegen Wartungsarbeiten ist diese Website kurzzeitig nicht verfügbar/) Expected: visible Timeout: 5000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 5000ms - waiting for getByText(/Wegen Wartungsarbeiten ist diese Website kurzzeitig nicht verfügbar/) 41 | await expect( 42 | page.getByText( /Wegen Wartungsarbeiten ist diese Website kurzzeitig nicht verfügbar/ ) > 43 | ).toBeVisible(); | ^ 44 | } ); 45 | } ); 46 | } ); at /home/runner/work/wordpress-develop/wordpress-develop/tests/e2e/specs/maintenance-mode.test.js:43:6
} );
} );
} );
159 changes: 159 additions & 0 deletions tests/phpunit/tests/l10n.php
Original file line number Diff line number Diff line change
Expand Up @@ -461,4 +461,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 match expected 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