@@ -43,3 +43,160 @@ 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 . split ( ';' , 2 ) . map ( ( v ) => v . trim ( ) ) ;
80
+
81
+ let quality = qualityValue ?. startsWith ( 'q=' ) ? parseFloat ( qualityValue . slice ( 2 ) ) : undefined ;
82
+ if ( typeof quality !== 'number' || isNaN ( quality ) || quality < 0 || quality > 1 ) {
83
+ quality = 1 ; // Invalid quality value defaults to 1
84
+ }
85
+
86
+ return [ locale , quality ] as const ;
87
+ } )
88
+ . sort ( ( [ _localeA , qualityA ] , [ _localeB , qualityB ] ) => qualityB - qualityA ) ;
89
+
90
+ return new Map ( parsedValues ) ;
91
+ }
92
+
93
+ /**
94
+ * Gets the preferred locale based on the highest quality value from the provided `Accept-Language` header
95
+ * and the set of available locales.
96
+ *
97
+ * This function adheres to the HTTP `Accept-Language` header specification as defined in
98
+ * [RFC 7231](https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.5), including:
99
+ * - Case-insensitive matching of language tags.
100
+ * - Quality value handling (e.g., `q=1`, `q=0.8`). If no quality value is provided, it defaults to `q=1`.
101
+ * - Prefix matching (e.g., `en` matching `en-US` or `en-GB`).
102
+ *
103
+ * @param header - The `Accept-Language` header string to parse and evaluate. It may contain multiple
104
+ * locales with optional quality values, for example: `'en-US;q=0.8,fr-FR;q=0.9'`.
105
+ * @param supportedLocales - An array of supported locales (e.g., `['en-US', 'fr-FR']`),
106
+ * representing the locales available in the application.
107
+ * @returns The best matching locale from the supported languages, or `undefined` if no match is found.
108
+ *
109
+ * @example
110
+ * ```js
111
+ * getPreferredLocale('en-US;q=0.8,fr-FR;q=0.9', ['en-US', 'fr-FR', 'de-DE'])
112
+ * // returns 'fr-FR'
113
+ *
114
+ * getPreferredLocale('en;q=0.9,fr-FR;q=0.8', ['en-US', 'fr-FR', 'de-DE'])
115
+ * // returns 'en-US'
116
+ *
117
+ * getPreferredLocale('es-ES;q=0.7', ['en-US', 'fr-FR', 'de-DE'])
118
+ * // returns undefined
119
+ * ```
120
+ */
121
+ export function getPreferredLocale (
122
+ header : string ,
123
+ supportedLocales : ReadonlyArray < string > ,
124
+ ) : string | undefined {
125
+ if ( supportedLocales . length < 2 ) {
126
+ return supportedLocales [ 0 ] ;
127
+ }
128
+
129
+ const parsedLocales = parseLanguageHeader ( header ) ;
130
+
131
+ // Handle edge cases:
132
+ // - No preferred locales provided.
133
+ // - Only one supported locale.
134
+ // - Wildcard preference.
135
+ if ( parsedLocales . size === 0 || ( parsedLocales . size === 1 && parsedLocales . has ( '*' ) ) ) {
136
+ return supportedLocales [ 0 ] ;
137
+ }
138
+
139
+ // Create a map for case-insensitive lookup of supported locales.
140
+ // Keys are normalized (lowercase) locale values, values are original casing.
141
+ const normalizedSupportedLocales = new Map < string , string > ( ) ;
142
+ for ( const locale of supportedLocales ) {
143
+ normalizedSupportedLocales . set ( normalizeLocale ( locale ) , locale ) ;
144
+ }
145
+
146
+ // Iterate through parsed locales in descending order of quality.
147
+ let bestMatch : string | undefined ;
148
+ const qualityZeroNormalizedLocales = new Set < string > ( ) ;
149
+ for ( const [ locale , quality ] of parsedLocales ) {
150
+ const normalizedLocale = normalizeLocale ( locale ) ;
151
+ if ( quality === 0 ) {
152
+ qualityZeroNormalizedLocales . add ( normalizedLocale ) ;
153
+ continue ; // Skip locales with quality value of 0.
154
+ }
155
+
156
+ // Exact match found.
157
+ if ( normalizedSupportedLocales . has ( normalizedLocale ) ) {
158
+ return normalizedSupportedLocales . get ( normalizedLocale ) ;
159
+ }
160
+
161
+ // If an exact match is not found, try prefix matching (e.g., "en" matches "en-US").
162
+ // Store the first prefix match encountered, as it has the highest quality value.
163
+ if ( bestMatch !== undefined ) {
164
+ continue ;
165
+ }
166
+
167
+ const [ languagePrefix ] = normalizedLocale . split ( '-' , 1 ) ;
168
+ for ( const supportedLocale of normalizedSupportedLocales . keys ( ) ) {
169
+ if ( supportedLocale . startsWith ( languagePrefix ) ) {
170
+ bestMatch = normalizedSupportedLocales . get ( supportedLocale ) ;
171
+ break ; // No need to continue searching for this locale.
172
+ }
173
+ }
174
+ }
175
+
176
+ if ( bestMatch !== undefined ) {
177
+ return bestMatch ;
178
+ }
179
+
180
+ // Return the first locale that is not quality zero.
181
+ for ( const [ normalizedLocale , locale ] of normalizedSupportedLocales ) {
182
+ if ( ! qualityZeroNormalizedLocales . has ( normalizedLocale ) ) {
183
+ return locale ;
184
+ }
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Normalizes a locale string by converting it to lowercase.
190
+ *
191
+ * @param locale - The locale string to normalize.
192
+ * @returns The normalized locale string in lowercase.
193
+ *
194
+ * @example
195
+ * ```ts
196
+ * const normalized = normalizeLocale('EN-US');
197
+ * console.log(normalized); // Output: "en-us"
198
+ * ```
199
+ */
200
+ function normalizeLocale ( locale : string ) : string {
201
+ return locale . toLowerCase ( ) ;
202
+ }
0 commit comments