Skip to content

Commit 73bc53e

Browse files
Tests
1 parent fd3651e commit 73bc53e

File tree

2 files changed

+63
-149
lines changed

2 files changed

+63
-149
lines changed

src/getUserLocalesFromBrowserLanguages.ts

Lines changed: 42 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -59,91 +59,61 @@ export function getLanguagesFromNavigator(
5959
return tags.map((t) => t.trim()).filter((x) => !!x) as BrowserLocaleKey[];
6060
}
6161

62-
/**
63-
* Case-insensitive lookup of a browser tag in LOCALE_BROWSER_MAP,
64-
* with a fallback to its base tag.
65-
*
66-
* @param tag - Browser tag (any case, e.g., 'Es-Mx')
67-
* @returns LocaleValue if found, otherwise undefined
68-
*/
69-
export function mapBrowserTagToLocale(tag: string): LocaleValue | undefined {
70-
// normalize language
71-
const lc = normalizeBrowserTag(tag);
72-
73-
// direct match if exists
74-
if (lc in LOCALE_BROWSER_MAP_LOWERCASE) {
75-
return LOCALE_BROWSER_MAP_LOWERCASE[lc];
76-
}
77-
78-
// otherwise try base prefix
79-
const baseLc = baseOf(lc);
80-
if (baseLc in LOCALE_BROWSER_MAP_LOWERCASE) {
81-
return LOCALE_BROWSER_MAP_LOWERCASE[baseLc];
82-
}
83-
84-
// no direct match
85-
return undefined;
86-
}
87-
88-
/**
89-
* Resolve the best supported LocaleValue for a single browser tag.
90-
* Rule:
91-
* 1) Map via LOCALE_BROWSER_MAP (case-insensitive); if supported, use it.
92-
* 2) Otherwise, use the browser tag’s base prefix:
93-
* a) if the short code (e.g., 'ar') is supported, use it
94-
* b) else pick the first 'ar-*' in supportedLocales (preserves customer order)
95-
*
96-
* @param browserTag - Browser tag (e.g., 'ar-EG')
97-
* @param supportedLocales - Allowed locales (customer-ordered)
98-
* @returns Supported LocaleValue or undefined if no base match exists
99-
*/
100-
export function resolveSupportedLocaleForBrowserTag(
101-
browserTag: string,
102-
supportedLocales: LocaleValue[],
103-
): LocaleValue | undefined {
104-
const supportedSet = new Set(supportedLocales);
105-
106-
// look for direct match and accept that if in list
107-
const mapped = mapBrowserTagToLocale(browserTag);
108-
if (mapped && supportedSet.has(mapped)) {
109-
return mapped;
110-
}
111-
112-
// if no direct match, look for base prefix matches e.g. "ar-EG" -> "ar"
113-
const prefixLc = baseOf(normalizeBrowserTag(browserTag));
114-
const shortMatch = supportedLocales.find((l) => l.toLowerCase() === prefixLc);
115-
if (shortMatch) {
116-
return shortMatch;
117-
}
118-
119-
// then first variant with same base
120-
const variantMatch = supportedLocales.find(
121-
(l) => l.includes('-') && baseOf(l).toLowerCase() === prefixLc,
122-
);
123-
return variantMatch;
124-
}
125-
12662
/**
12763
* Map an ordered list of browser tags to supported LocaleValues using the resolve rule.
128-
* Keeps first-seen order from the browser list and de-duplicates.
129-
* Falls back to default if nothing matches.
64+
* De-duplicates and preserves order, but prioritizes **exact LOCALE_BROWSER_MAP hits**
65+
* over fuzzy/base matches across the whole list.
13066
*
13167
* @param browserLocales - Browser tags (ordered by user preference)
13268
* @param supportedLocales - Allowed locales (customer-ordered)
13369
* @param defaultLocale - Fallback when nothing matches (defaults to 'en')
134-
* @returns Ordered, unique supported LocaleValues
70+
* @returns Ordered, unique supported LocaleValues with exact hits first
13571
*/
13672
export function getUserLocalesFromBrowserLanguages(
13773
browserLocales: string[],
13874
supportedLocales: LocaleValue[],
13975
defaultLocale: LocaleValue,
14076
): LocaleValue[] {
141-
const resolved = browserLocales
142-
.map((tag) => resolveSupportedLocaleForBrowserTag(tag, supportedLocales))
143-
.filter((x): x is LocaleValue => Boolean(x));
77+
const supportedSet = new Set(supportedLocales);
78+
79+
const exact: LocaleValue[] = [];
80+
const fuzzy: LocaleValue[] = [];
81+
82+
// eslint-disable-next-line no-restricted-syntax
83+
for (const tag of browserLocales) {
84+
const lc = normalizeBrowserTag(tag);
85+
86+
// 1) Exact LOCALE_BROWSER_MAP match (case-insensitive)
87+
const direct = LOCALE_BROWSER_MAP_LOWERCASE[lc];
88+
if (direct && supportedSet.has(direct)) {
89+
exact.push(direct);
90+
// eslint-disable-next-line no-continue
91+
continue;
92+
}
93+
94+
// 2) Fuzzy prefix rule against *supportedLocales*:
95+
const prefix = baseOf(lc);
96+
97+
// 2a) short/base code if supported
98+
const short = supportedLocales.find((l) => l.toLowerCase() === prefix);
99+
if (short) {
100+
fuzzy.push(short);
101+
// eslint-disable-next-line no-continue
102+
continue;
103+
}
104+
105+
// 2b) otherwise first variant of same base in customer order
106+
const variant = supportedLocales.find(
107+
(l) => l.includes('-') && baseOf(l).toLowerCase() === prefix,
108+
);
109+
if (variant) {
110+
fuzzy.push(variant);
111+
}
112+
}
144113

145-
const unique = uniqOrdered(resolved);
146-
return unique.length ? unique : [defaultLocale];
114+
// Exact hits outrank any fuzzy/base matches globally
115+
const ordered = uniqOrdered<LocaleValue>([...exact, ...fuzzy]);
116+
return ordered.length ? ordered : [defaultLocale];
147117
}
148118

149119
/**

src/tests/getUserLocalesFromBrowserLanguages.test.ts

Lines changed: 21 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ import {
88

99
import {
1010
getLanguagesFromNavigator,
11-
mapBrowserTagToLocale,
12-
resolveSupportedLocaleForBrowserTag,
1311
getUserLocalesFromBrowserLanguages,
1412
getNearestSupportedLocale,
1513
sortSupportedLocalesByPreference,
@@ -41,81 +39,6 @@ describe('locale-helpers', () => {
4139
});
4240
});
4341

44-
describe('mapBrowserTagToLocale (case-insensitive + base fallback)', () => {
45-
it('maps exact tags regardless of case', () => {
46-
expect(mapBrowserTagToLocale('fr-CA')).to.equal(LOCALE_KEY.FrCa);
47-
expect(mapBrowserTagToLocale('FR-ca')).to.equal(LOCALE_KEY.FrCa);
48-
expect(mapBrowserTagToLocale('En-US')).to.equal(LOCALE_KEY.EnUs);
49-
});
50-
51-
it('falls back to base when the specific tag is not present', () => {
52-
// no specific fr-XX -> use base mapping 'fr'
53-
expect(mapBrowserTagToLocale('fr-XX')).to.equal(LOCALE_KEY.Fr);
54-
// zh-hant-XX -> base is 'zh' (which maps to ZhCn in the browser map)
55-
expect(mapBrowserTagToLocale('ZH-hant-XX')).to.equal(LOCALE_KEY.ZhCn);
56-
});
57-
58-
it('returns undefined when neither exact nor base exist', () => {
59-
expect(mapBrowserTagToLocale('zz-QQ')).to.equal(undefined);
60-
});
61-
});
62-
63-
describe('resolveSupportedLocaleForBrowserTag', () => {
64-
const SUPPORTED_DEMO: LocaleValue[] = [
65-
LOCALE_KEY.Ar, // base arabic
66-
LOCALE_KEY.ArAe, // a variant
67-
LOCALE_KEY.FrFr,
68-
LOCALE_KEY.FrCa,
69-
LOCALE_KEY.EnUs,
70-
LOCALE_KEY.EnGb,
71-
LOCALE_KEY.EsEs,
72-
LOCALE_KEY.ZhCn,
73-
LOCALE_KEY.ZhHk,
74-
];
75-
76-
it('returns mapped locale when it is supported', () => {
77-
expect(
78-
resolveSupportedLocaleForBrowserTag('fr-CA', SUPPORTED_DEMO),
79-
).to.equal(LOCALE_KEY.FrCa);
80-
});
81-
82-
it('when mapped is unsupported, uses short base if it is supported', () => {
83-
// base 'ar' is supported
84-
expect(
85-
resolveSupportedLocaleForBrowserTag('ar-EG', SUPPORTED_DEMO),
86-
).to.equal(LOCALE_KEY.Ar);
87-
});
88-
89-
it('when short base not supported, picks first variant of same base (customer order respected)', () => {
90-
const supported: LocaleValue[] = [
91-
LOCALE_KEY.ArAe,
92-
LOCALE_KEY.FrFr,
93-
LOCALE_KEY.EnUs,
94-
];
95-
expect(resolveSupportedLocaleForBrowserTag('ar-OM', supported)).to.equal(
96-
LOCALE_KEY.ArAe,
97-
);
98-
});
99-
100-
it('when short base not supported, picks first variant of same base (customer order respected)', () => {
101-
const supported: LocaleValue[] = [
102-
LOCALE_KEY.ArAe,
103-
LOCALE_KEY.FrFr,
104-
LOCALE_KEY.EnUs,
105-
];
106-
expect(resolveSupportedLocaleForBrowserTag('ar', supported)).to.equal(
107-
LOCALE_KEY.ArAe,
108-
);
109-
});
110-
111-
it('returns undefined if no base or variant is supported', () => {
112-
const supported: LocaleValue[] = [LOCALE_KEY.FrFr, LOCALE_KEY.EnUs];
113-
expect(resolveSupportedLocaleForBrowserTag('ar-OM', supported)).to.equal(
114-
undefined,
115-
);
116-
});
117-
});
118-
11942
describe('getUserLocalesFromBrowserLanguages', () => {
12043
it('produces ordered, unique list constrained by supported', () => {
12144
const supported: LocaleValue[] = [
@@ -146,6 +69,27 @@ describe('locale-helpers', () => {
14669
expect(res).to.deep.equal([LOCALE_KEY.EnUs]);
14770
});
14871

72+
it('prioritizes a later exact LOCALE_BROWSER_MAP hit over an earlier fuzzy/base match', () => {
73+
// Supported has both a base (Ar) and a specific variant (ArAe)
74+
const supported: LocaleValue[] = [
75+
LOCALE_KEY.Ar,
76+
LOCALE_KEY.ArAe,
77+
LOCALE_KEY.EnUs,
78+
];
79+
80+
// Browser says a fuzzy base-match first (ar-OM -> Ar), then an exact map later (ar-AE -> ArAe)
81+
const browser = ['ar-OM', 'ar-AE'];
82+
83+
const res = getUserLocalesFromBrowserLanguages(
84+
browser,
85+
supported,
86+
LOCALE_KEY.EnUs,
87+
);
88+
89+
// Exact (ArAe) should be before fuzzy/base (Ar)
90+
expect(res).to.deep.equal([LOCALE_KEY.ArAe, LOCALE_KEY.Ar]);
91+
});
92+
14993
it('short beats variant if both are supported', () => {
15094
const supported = [LOCALE_KEY.ArAe, LOCALE_KEY.Ar];
15195
const res = getUserLocalesFromBrowserLanguages(

0 commit comments

Comments
 (0)