@@ -43,3 +43,158 @@ 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
+ const quality = qualityValue ?. startsWith ( 'q=' ) ? parseFloat ( qualityValue . slice ( 2 ) ) : 1 ;
85
+
86
+ return [ locale , quality ] as const ;
87
+ } )
88
+ . sort ( ( a , b ) => b [ 1 ] - a [ 1 ] ) ;
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. If no exact match is found, it attempts to find the closest match
96
+ * based on language prefixes (e.g., `en` matching `en-US` or `en-GB`).
97
+ *
98
+ * The function considers the quality values (`q=<value>`) in the `Accept-Language` header. If no quality
99
+ * value is provided, it defaults to `q=1`. The function returns the locale from `supportedLocales`
100
+ * with the highest quality value. If no suitable match is found, it returns `null`.
101
+ *
102
+ * @param header - The `Accept-Language` header string to parse and evaluate. It may contain multiple
103
+ * locales with optional quality values, for example: `'en-US;q=0.8,fr-FR;q=0.9'`.
104
+ * @param supportedLocales - A readonly set of supported locales (e.g., `new Set(['en-US', 'fr-FR'])`),
105
+ * representing the locales available in the application.
106
+ * @returns The best matching locale from the supported languages, or `null` if no match is found.
107
+ *
108
+ * @example
109
+ * ```js
110
+ * getPreferredLocale('en-US;q=0.8,fr-FR;q=0.9', new Set(['en-US', 'fr-FR', 'de-DE']))
111
+ * // returns 'fr-FR'
112
+ *
113
+ * getPreferredLocale('en;q=0.9,fr-FR;q=0.8', new Set(['en-US', 'fr-FR', 'de-DE']))
114
+ * // returns 'en-US'
115
+ *
116
+ * getPreferredLocale('es-ES;q=0.7', new Set(['en-US', 'fr-FR', 'de-DE']))
117
+ * // returns undefined
118
+ * ```
119
+ */
120
+ export function getPreferredLocale (
121
+ header : string ,
122
+ supportedLocales : ReadonlySet < string > ,
123
+ ) : string | undefined {
124
+ const parsedLocales = parseLanguageHeader ( header ) ;
125
+ if (
126
+ parsedLocales . size === 0 ||
127
+ supportedLocales . size === 1 ||
128
+ ( parsedLocales . size === 1 && parsedLocales . has ( '*' ) )
129
+ ) {
130
+ return supportedLocales . values ( ) . next ( ) . value as string ;
131
+ }
132
+
133
+ // First, try to find the best exact match
134
+ // If no exact match, try to find the best loose match
135
+ const match =
136
+ getBestExactMatch ( parsedLocales , supportedLocales ) ??
137
+ getBestLooseMatch ( parsedLocales , supportedLocales ) ;
138
+ if ( match ) {
139
+ return match ;
140
+ }
141
+
142
+ // Return the first locale that is not quality zero.
143
+ for ( const locale of supportedLocales ) {
144
+ if ( parsedLocales . get ( locale ) !== 0 ) {
145
+ return locale ;
146
+ }
147
+ }
148
+
149
+ return undefined ;
150
+ }
151
+
152
+ /**
153
+ * Finds the best exact match for the parsed locales from the supported languages.
154
+ * @param parsedLocales - A read-only map of parsed locales with their associated quality values.
155
+ * @param supportedLocales - A set of supported languages.
156
+ * @returns The best matching locale from the supported languages or undefined if no match is found.
157
+ */
158
+ function getBestExactMatch (
159
+ parsedLocales : ReadonlyMap < string , number > ,
160
+ supportedLocales : ReadonlySet < string > ,
161
+ ) : string | undefined {
162
+ for ( const [ locale , quality ] of parsedLocales ) {
163
+ if ( quality === 0 ) {
164
+ continue ;
165
+ }
166
+
167
+ if ( supportedLocales . has ( locale ) ) {
168
+ return locale ;
169
+ }
170
+ }
171
+
172
+ return undefined ;
173
+ }
174
+
175
+ /**
176
+ * Finds the best loose match for the parsed locales from the supported languages.
177
+ * A loose match is a match where the locale's prefix matches a supported language.
178
+ * @param parsedLocales - A read-only map of parsed locales with their associated quality values.
179
+ * @param supportedLocales - A set of supported languages.
180
+ * @returns The best loose matching locale from the supported languages or undefined if no match is found.
181
+ */
182
+ function getBestLooseMatch (
183
+ parsedLocales : ReadonlyMap < string , number > ,
184
+ supportedLocales : ReadonlySet < string > ,
185
+ ) : string | undefined {
186
+ for ( const [ locale , quality ] of parsedLocales ) {
187
+ if ( quality === 0 ) {
188
+ continue ;
189
+ }
190
+
191
+ const [ languagePrefix ] = locale . split ( '-' , 1 ) ;
192
+ for ( const supportedLocale of supportedLocales ) {
193
+ if ( supportedLocale . startsWith ( languagePrefix ) ) {
194
+ return supportedLocale ;
195
+ }
196
+ }
197
+ }
198
+
199
+ return undefined ;
200
+ }
0 commit comments