@@ -43,3 +43,147 @@ export function getPotentialLocaleIdFromUrl(url: URL, basePath: string): string
43
43
// Extract the potential locale id.
44
44
return pathname . slice ( start , end ) ;
45
45
}
46
+
47
+ /**
48
+ * Parses the `Accept-Language` header and returns a list of locale preferences with their respective quality values.
49
+ *
50
+ * The `Accept-Language` header is typically a comma-separated list of locales, with optional quality values
51
+ * in the form of `q=<value>`. If no quality value is specified, a default quality of `1` is assumed.
52
+ * Special case: if the header is `*`, it returns the default locale with a quality of `1`.
53
+ *
54
+ * @param header - The value of the `Accept-Language` header, typically a comma-separated list of locales
55
+ * with optional quality values (e.g., `en-US;q=0.8,fr-FR;q=0.9`). If the header is `*`,
56
+ * it represents a wildcard for any language, returning the default locale.
57
+ *
58
+ * @returns A `ReadonlyMap` where the key is the locale (e.g., `en-US`, `fr-FR`), and the value is
59
+ * the associated quality value (a number between 0 and 1). If no quality value is provided,
60
+ * a default of `1` is used.
61
+ *
62
+ * @example
63
+ * ```js
64
+ * parseLanguageHeader('en-US;q=0.8,fr-FR;q=0.9')
65
+ * // returns new Map([['en-US', 0.8], ['fr-FR', 0.9]])
66
+
67
+ * parseLanguageHeader('*')
68
+ * // returns new Map([['*', 1]])
69
+ * ```
70
+ */
71
+ function parseLanguageHeader ( header : string ) : ReadonlyMap < string , number > {
72
+ if ( header === '*' ) {
73
+ return new Map ( [ [ '*' , 1 ] ] ) ;
74
+ }
75
+
76
+ const parsedValues = header
77
+ . split ( ',' )
78
+ . map ( ( item ) => {
79
+ const [ locale , qualityValue ] = item
80
+ . trim ( )
81
+ . split ( ';' , 2 )
82
+ . map ( ( v ) => v . trim ( ) ) ;
83
+
84
+ let quality = qualityValue ?. startsWith ( 'q=' ) ? parseFloat ( qualityValue . slice ( 2 ) ) : undefined ;
85
+ if ( typeof quality !== 'number' || isNaN ( quality ) || quality < 0 || quality > 1 ) {
86
+ quality = 1 ; // Invalid quality value defaults to 1
87
+ }
88
+
89
+ return [ locale , quality ] as const ;
90
+ } )
91
+ . sort ( ( a , b ) => b [ 1 ] - a [ 1 ] ) ;
92
+
93
+ return new Map ( parsedValues ) ;
94
+ }
95
+
96
+ /**
97
+ * Gets the preferred locale based on the highest quality value from the provided `Accept-Language` header
98
+ * and the set of available locales.
99
+ *
100
+ * This function adheres to the HTTP `Accept-Language` header specification as defined in
101
+ * [RFC 7231](https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.5), including:
102
+ * - Case-insensitive matching of language tags.
103
+ * - Quality value handling (e.g., `q=1`, `q=0.8`). If no quality value is provided, it defaults to `q=1`.
104
+ * - Prefix matching (e.g., `en` matching `en-US` or `en-GB`).
105
+ *
106
+ * @param header - The `Accept-Language` header string to parse and evaluate. It may contain multiple
107
+ * locales with optional quality values, for example: `'en-US;q=0.8,fr-FR;q=0.9'`.
108
+ * @param supportedLocales - An array of supported locales (e.g., `['en-US', 'fr-FR']`),
109
+ * representing the locales available in the application.
110
+ * @returns The best matching locale from the supported languages, or `undefined` if no match is found.
111
+ *
112
+ * @example
113
+ * ```js
114
+ * getPreferredLocale('en-US;q=0.8,fr-FR;q=0.9', ['en-US', 'fr-FR', 'de-DE'])
115
+ * // returns 'fr-FR'
116
+ *
117
+ * getPreferredLocale('en;q=0.9,fr-FR;q=0.8', ['en-US', 'fr-FR', 'de-DE'])
118
+ * // returns 'en-US'
119
+ *
120
+ * getPreferredLocale('es-ES;q=0.7', ['en-US', 'fr-FR', 'de-DE'])
121
+ * // returns undefined
122
+ * ```
123
+ */
124
+ export function getPreferredLocale (
125
+ header : string ,
126
+ supportedLocales : ReadonlyArray < string > ,
127
+ ) : string | undefined {
128
+ const parsedLocales = parseLanguageHeader ( header ) ;
129
+
130
+ // Handle edge cases:
131
+ // - No preferred locales provided.
132
+ // - Only one supported locale.
133
+ // - Wildcard preference.
134
+ if (
135
+ parsedLocales . size === 0 ||
136
+ supportedLocales . length < 2 ||
137
+ ( parsedLocales . size === 1 && parsedLocales . has ( '*' ) )
138
+ ) {
139
+ return supportedLocales [ 0 ] ;
140
+ }
141
+
142
+ // Create a map for case-insensitive lookup of supported locales.
143
+ // Keys are normalized (lowercase) locale values, values are original casing.
144
+ const normalizedSupportedLocales = new Map < string , string > ( ) ;
145
+ for ( const locale of supportedLocales ) {
146
+ normalizedSupportedLocales . set ( locale . toLowerCase ( ) , locale ) ;
147
+ }
148
+
149
+ // Iterate through parsed locales in descending order of quality.
150
+ let bestMatch : string | undefined ;
151
+ const qualityZeroNormalizedLocales = new Set < string > ( ) ;
152
+ for ( const [ locale , quality ] of parsedLocales ) {
153
+ const normalizedLocale = locale . toLowerCase ( ) ;
154
+ if ( quality === 0 ) {
155
+ qualityZeroNormalizedLocales . add ( normalizedLocale ) ;
156
+ continue ; // Skip locales with quality value of 0.
157
+ }
158
+
159
+ // Exact match found.
160
+ if ( normalizedSupportedLocales . has ( normalizedLocale ) ) {
161
+ return normalizedSupportedLocales . get ( normalizedLocale ) ;
162
+ }
163
+
164
+ // If an exact match is not found, try prefix matching (e.g., "en" matches "en-US").
165
+ // Store the first prefix match encountered, as it has the highest quality value.
166
+ if ( bestMatch !== undefined ) {
167
+ continue ;
168
+ }
169
+
170
+ const [ languagePrefix ] = normalizedLocale . split ( '-' , 1 ) ;
171
+ for ( const supportedLocale of normalizedSupportedLocales . keys ( ) ) {
172
+ if ( supportedLocale . startsWith ( languagePrefix ) ) {
173
+ bestMatch = normalizedSupportedLocales . get ( supportedLocale ) ;
174
+ break ; // No need to continue searching for this locale.
175
+ }
176
+ }
177
+ }
178
+
179
+ if ( bestMatch !== undefined ) {
180
+ return bestMatch ;
181
+ }
182
+
183
+ // Return the first locale that is not quality zero.
184
+ for ( const [ normalizedLocale , locale ] of normalizedSupportedLocales ) {
185
+ if ( ! qualityZeroNormalizedLocales . has ( normalizedLocale ) ) {
186
+ return locale ;
187
+ }
188
+ }
189
+ }
0 commit comments