-
Notifications
You must be signed in to change notification settings - Fork 0
Adds helper functions for mapping browser languages to locale keys #27
base: main
Are you sure you want to change the base?
Changes from 3 commits
fcf1c3b
7785628
edbb1ce
e444b81
fd3651e
73bc53e
d4edab7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,245 @@ | ||
| import { | ||
| LocaleValue, | ||
| BrowserLocaleKey, | ||
| LOCALE_BROWSER_MAP_LOWERCASE, | ||
| } from './enums'; | ||
|
|
||
| /** | ||
| * Normalize a BCP-47 browser language tag to lowercase. | ||
| * | ||
| * @param tag - Raw browser language tag (e.g., 'en-US') | ||
| * @returns Lowercased tag (e.g., 'en-us') | ||
| */ | ||
| function normalizeBrowserTag(tag: string): string { | ||
| return tag.trim().toLowerCase(); | ||
| } | ||
|
|
||
| /** | ||
| * Extract the base language sub-tag from a BCP-47 tag or LocaleValue. | ||
| * | ||
| * @param code - A tag or LocaleValue (e.g., 'fr-CA' or 'fr') | ||
| * @returns Base language (e.g., 'fr') | ||
| */ | ||
| function baseOf(code: string): string { | ||
| return code.split('-')[0]; | ||
| } | ||
|
|
||
| /** | ||
| * Return a de-duplicated array preserving first-seen order. | ||
| * | ||
| * @param items - Input items | ||
| * @returns Unique items in original order | ||
| */ | ||
| function uniqOrdered<T>(items: T[]): T[] { | ||
| const out: T[] = []; | ||
| const seen = new Set<T>(); | ||
| // eslint-disable-next-line no-restricted-syntax | ||
| for (const x of items) { | ||
| if (!seen.has(x)) { | ||
| seen.add(x); | ||
| out.push(x); | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
| /** | ||
| * Detect user-preferred languages from the navigator. | ||
| * We only trim; keys in LOCALE_BROWSER_MAP can be mixed case, and resolution is case-insensitive. | ||
| * | ||
| * @param languages - navigator.languages | ||
| * @param language - navigator.language | ||
| * @returns Ordered list of BCP-47 tags (strings) | ||
| */ | ||
| export function getLanguagesFromNavigator( | ||
| languages = navigator.languages, | ||
| language = navigator.language, | ||
| ): BrowserLocaleKey[] { | ||
| const tags = ( | ||
| languages?.length ? languages : [language] | ||
| ) as BrowserLocaleKey[]; | ||
| return tags.map((t) => t.trim()).filter((x) => !!x); | ||
|
Check failure on line 61 in src/getUserLocalesFromBrowserLanguages.ts
|
||
| } | ||
|
|
||
| /** | ||
| * Case-insensitive lookup of a browser tag in LOCALE_BROWSER_MAP, | ||
| * with a fallback to its base tag. | ||
| * | ||
| * @param tag - Browser tag (any case, e.g., 'Es-Mx') | ||
| * @returns LocaleValue if found, otherwise undefined | ||
| */ | ||
| export function mapBrowserTagToLocale(tag: string): LocaleValue | undefined { | ||
| // normalize language | ||
| const lc = normalizeBrowserTag(tag); | ||
|
|
||
| // direct match if exists | ||
| if (lc in LOCALE_BROWSER_MAP_LOWERCASE) { | ||
| return LOCALE_BROWSER_MAP_LOWERCASE[lc]; | ||
| } | ||
|
|
||
| // otherwise try base prefix | ||
| const baseLc = baseOf(lc); | ||
| if (baseLc in LOCALE_BROWSER_MAP_LOWERCASE) { | ||
| return LOCALE_BROWSER_MAP_LOWERCASE[baseLc]; | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. does it make sense to have both this and the base lang fallback in resolveSupportedLocaleForBrowserTag? |
||
|
|
||
| // no direct match | ||
| return undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Resolve the best supported LocaleValue for a single browser tag. | ||
| * Rule: | ||
| * 1) Map via LOCALE_BROWSER_MAP (case-insensitive); if supported, use it. | ||
| * 2) Otherwise, use the browser tag’s base prefix: | ||
| * a) if the short code (e.g., 'ar') is supported, use it | ||
| * b) else pick the first 'ar-*' in supportedLocales (preserves customer order) | ||
| * | ||
| * @param browserTag - Browser tag (e.g., 'ar-EG') | ||
| * @param supportedLocales - Allowed locales (customer-ordered) | ||
| * @returns Supported LocaleValue or undefined if no base match exists | ||
| */ | ||
| export function resolveSupportedLocaleForBrowserTag( | ||
| browserTag: string, | ||
| supportedLocales: LocaleValue[], | ||
| ): LocaleValue | undefined { | ||
| const supportedSet = new Set(supportedLocales); | ||
|
|
||
| // look for direct match and accept that if in list | ||
| const mapped = mapBrowserTagToLocale(browserTag); | ||
| if (mapped && supportedSet.has(mapped)) { | ||
| return mapped; | ||
| } | ||
|
|
||
| // if no direct match, look for base prefix matches e.g. "ar-EG" -> "ar" | ||
| const prefixLc = baseOf(normalizeBrowserTag(browserTag)); | ||
| const shortMatch = supportedLocales.find((l) => l.toLowerCase() === prefixLc); | ||
| if (shortMatch) { | ||
| return shortMatch; | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. im not sure that this makes sense to implement - if we assume that we add a base lang and all its sublocale BCP 47 codes to our LOCALE_BROWSER_MAP at a time, then the only time this would trigger is e.g.
i think this feels a little murky to me for 3 reasons:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i definitely think that if customer has "fr" enabled, but not "fr-CA" and "fr-CA" is locale, we should default to "fr" instead of "en"....
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i can definitely adjust the logic to favor an exact match on second over a fuzzy match on first! but i def think that in above example fr-CA should map to fr > en if those are the two best options available
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. im just calling out that i dont think it would necessarily be universally desired. we can settle on that, but fuzzy locale matching has historically been an extremely sticky topic for us when it comes up on tickets and such. ease of understanding the matching logic may be a point towards just trying to go straight through LOCALE_BROWSER_MAP and failover otherwise, esp with all the new locale keys were adding making that exact matching more robust
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @csmccarthy but thats not even how it worked today. the way it works today actually seems to be more accepting of fallbacks like this. the logic seemed to be looking and saying "both fr-CA, fr, and fr-FR map to fr locale in AWS translations, so they are all interchangable". this at least makes the logic more obvious about how we fallback...
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i thought about something like "just use LOCALE_BROWSER_MAP" but we'd have to change it to something like { "ar": ["ar", "ar-AR" ,...] and the complexity of maintaining that and making sure folks edit correctly is a bit high. i think the rules here of
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i dont really agree, but i dont feel strongly enough to argue it out in comments on the tests. i think once we have the exact > fuzzy match change in then we can merge
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ok i can do that. i’m definitely open to hearing you out, but id like to get specific test examples for the counter. this logic was very hard to parse without any tests. it's quite tricky to know what was intentional vs overlooked, and how a change to one of these functions results in different corner cases changing. i feel like what you’re arguing would result in browser language “ar” mapping to “en”
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. agreed it was hard to grok without tests lol, the logic is pretty rough the tldr is what i was advocating for would result in a situation where if i go into the admin dash and remove the i need to head out the door to get some labwork done before my fmla leave! i can respond again when im back home (or on slack)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. kk no problem, i may get this merged in so that i can start updating the functions in monorepo and consent uis, but before getting the final pr merged in main, im definitely happy to make sure we flesh this out. at the very least, it should now be easier to make changes to the logic in one place with some quick unit tests. what do you think about situation where browser locale is "ar-AE" and list of supported locales is "ar", "fr", "en", and "ar-AE" is not supported - do you think it should fallback to ar?
the situation you describe makes total sense to me as a valid concern... i think, at least for now, it makes sense to start with what we have here as a first step for the following reasons:
|
||
|
|
||
| // then first variant with same base | ||
| const variantMatch = supportedLocales.find( | ||
| (l) => l.includes('-') && baseOf(l).toLowerCase() === prefixLc, | ||
| ); | ||
|
csmccarthy marked this conversation as resolved.
Outdated
|
||
| return variantMatch; | ||
| } | ||
|
|
||
| /** | ||
| * Map an ordered list of browser tags to supported LocaleValues using the resolve rule. | ||
| * Keeps first-seen order from the browser list and de-duplicates. | ||
| * Falls back to default if nothing matches. | ||
| * | ||
| * @param browserLocales - Browser tags (ordered by user preference) | ||
| * @param supportedLocales - Allowed locales (customer-ordered) | ||
| * @param defaultLocale - Fallback when nothing matches (defaults to 'en') | ||
| * @returns Ordered, unique supported LocaleValues | ||
| */ | ||
| export function getUserLocalesFromBrowserLanguages( | ||
| browserLocales: string[], | ||
| supportedLocales: LocaleValue[], | ||
| defaultLocale: LocaleValue, | ||
| ): LocaleValue[] { | ||
| const resolved = browserLocales | ||
| .map((tag) => resolveSupportedLocaleForBrowserTag(tag, supportedLocales)) | ||
| .filter((x): x is LocaleValue => Boolean(x)); | ||
|
|
||
| const unique = uniqOrdered(resolved); | ||
| return unique.length ? unique : [defaultLocale]; | ||
| } | ||
|
|
||
| /** | ||
| * Return the first preferred locale that is supported. | ||
| * Pure membership check—no external equivalence. | ||
| * | ||
| * @param preferred - Candidate locales in descending preference | ||
| * @param supported - Allowed locales | ||
| * @returns First supported locale or undefined | ||
| */ | ||
| export function getNearestSupportedLocale( | ||
| preferred: LocaleValue[], | ||
| supported: LocaleValue[], | ||
| ): LocaleValue | undefined { | ||
| const set = new Set(supported); | ||
| // eslint-disable-next-line no-restricted-syntax | ||
| for (const p of preferred) { | ||
| if (set.has(p)) return p; | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Sort a provided list of locales by the user’s preferences. | ||
| * Exact matches rank before base-only matches; otherwise original order is preserved. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. afaict this comment as written isnt true, since were adding the base only fuzzy matches to the users preferred locale array |
||
| * | ||
| * @param languages - Locales to sort (subset of supported) | ||
| * @param userPreferredLocales - Preferred locales (e.g., output of getUserLocalesFromBrowserLanguages) | ||
| * @returns languages sorted by preference (stable) | ||
| */ | ||
| export function sortSupportedLocalesByPreference<T extends LocaleValue>( | ||
| languages: T[], | ||
| userPreferredLocales: LocaleValue[], | ||
| ): T[] { | ||
| const exactOrder = new Map<LocaleValue, number>(); | ||
| userPreferredLocales.forEach((v, i) => exactOrder.set(v, i)); | ||
|
|
||
| const baseOrder = new Map<string, number>(); | ||
| uniqOrdered(userPreferredLocales.map((v) => baseOf(v).toLowerCase())).forEach( | ||
| (b, i) => baseOrder.set(b, i), | ||
| ); | ||
|
|
||
| const score = (l: T): number => { | ||
| const exact = exactOrder.get(l); | ||
| if (exact !== undefined) return exact; | ||
| const bIdx = baseOrder.get(baseOf(l).toLowerCase()); | ||
| if (bIdx !== undefined) return 1000 + bIdx; | ||
| return Number.POSITIVE_INFINITY; | ||
| }; | ||
|
|
||
| return [...languages].sort((a, b) => score(a) - score(b)); | ||
| } | ||
|
|
||
| /** | ||
| * Compute the single default language for the user using browser order. | ||
| * This will try base prefix matches (e.g., 'zh' or any 'zh-*') among supported | ||
| * before falling back to the provided fallback. | ||
| * | ||
| * @param supportedLocales - Allowed locales (customer-ordered) | ||
| * @param browserLocales - Browser tags (ordered by user preference) | ||
| * @param fallback - Fallback locale (defaults to 'en') | ||
| * @returns Chosen LocaleValue | ||
| */ | ||
| export function pickDefaultLanguage( | ||
| supportedLocales: LocaleValue[], | ||
| browserLocales: string[], | ||
| fallback: LocaleValue, | ||
| ): LocaleValue { | ||
| const preferred = getUserLocalesFromBrowserLanguages( | ||
| browserLocales, | ||
| supportedLocales, | ||
| fallback, | ||
| ); | ||
| return getNearestSupportedLocale(preferred, supportedLocales) ?? fallback; | ||
| } | ||
|
|
||
| /** | ||
| * Given a customer-configured, ordered list of allowed locales, return that same list | ||
| * re-ordered by the user’s browser preferences using the prefix rule. | ||
| * | ||
| * @param customerLocales - Allowed locales in display/config order | ||
| * @param browserLocales - Browser tags (e.g., from getLanguagesFromNavigator()) | ||
| * @param fallback - Fallback when no signal matches | ||
| * @returns customerLocales sorted by user preference | ||
| */ | ||
| export function orderCustomerLocalesForDisplay( | ||
| customerLocales: LocaleValue[], | ||
| browserLocales: string[], | ||
| fallback: LocaleValue, | ||
| ): LocaleValue[] { | ||
| const preferred = getUserLocalesFromBrowserLanguages( | ||
| browserLocales, | ||
| customerLocales, | ||
| fallback, | ||
| ); | ||
| return sortSupportedLocalesByPreference(customerLocales, preferred); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.