From fcf1c3b22ab70a034f278d26caea53b1bd7201cb Mon Sep 17 00:00:00 2001 From: michaelfarrell76 Date: Fri, 10 Oct 2025 13:30:37 -0700 Subject: [PATCH 1/7] Adds locale helpers --- package.json | 2 +- src/enums.ts | 12 + src/getUserLocalesFromBrowserLanguages.ts | 239 +++++++++++++++ src/index.ts | 1 + ...getUserLocalesFromBrowserLanguages.test.ts | 284 ++++++++++++++++++ 5 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 src/getUserLocalesFromBrowserLanguages.ts create mode 100644 src/tests/getUserLocalesFromBrowserLanguages.test.ts diff --git a/package.json b/package.json index 26a426b..957f1f5 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Transcend Inc.", "name": "@transcend-io/internationalization", "description": "Internationalization configuration for the monorepo", - "version": "2.2.0", + "version": "2.3.0", "homepage": "https://github.com/transcend-io/internationalization", "repository": { "type": "git", diff --git a/src/enums.ts b/src/enums.ts index 8cc11a1..7154efa 100644 --- a/src/enums.ts +++ b/src/enums.ts @@ -1603,6 +1603,18 @@ export const LOCALE_BROWSER_MAP = { /** Union of Browser locale keys */ export type BrowserLocaleKey = keyof typeof LOCALE_BROWSER_MAP; +/** Case-insensitive index for browser tag → LocaleValue */ +export const LOCALE_BROWSER_MAP_LOWERCASE = Object.entries( + LOCALE_BROWSER_MAP, +).reduce( + (idx, [k, v]) => { + // eslint-disable-next-line no-param-reassign + idx[k.toLowerCase()] = v; + return idx; + }, + {} as Record, +); + /** * Native language names, used to render options to users * Language options for end-users should be written in own language diff --git a/src/getUserLocalesFromBrowserLanguages.ts b/src/getUserLocalesFromBrowserLanguages.ts new file mode 100644 index 0000000..5c85c19 --- /dev/null +++ b/src/getUserLocalesFromBrowserLanguages.ts @@ -0,0 +1,239 @@ +import { LocaleValue, 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(items: T[]): T[] { + const out: T[] = []; + const seen = new Set(); + // 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, +): string[] { + const tags = (languages?.length ? languages : [language]) as string[]; + return tags.map((t) => t.trim()).filter((x) => !!x); +} + +/** + * 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]; + } + + // 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; + } + + // then first variant with same base + const variantMatch = supportedLocales.find( + (l) => l.includes('-') && baseOf(l).toLowerCase() === prefixLc, + ); + 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. + * + * @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( + languages: T[], + userPreferredLocales: LocaleValue[], +): T[] { + const exactOrder = new Map(); + userPreferredLocales.forEach((v, i) => exactOrder.set(v, i)); + + const baseOrder = new Map(); + 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); +} diff --git a/src/index.ts b/src/index.ts index 9202e23..33ce5ad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,3 +2,4 @@ export * from './enums'; export * from './types'; export * from './typeGuards'; export * from './defineMessages'; +export * from './getUserLocalesFromBrowserLanguages'; diff --git a/src/tests/getUserLocalesFromBrowserLanguages.test.ts b/src/tests/getUserLocalesFromBrowserLanguages.test.ts new file mode 100644 index 0000000..c178166 --- /dev/null +++ b/src/tests/getUserLocalesFromBrowserLanguages.test.ts @@ -0,0 +1,284 @@ +import { expect } from 'chai'; + +import { + LOCALE_KEY, + CONSENT_MANAGER_SUPPORTED_LOCALES, + LocaleValue, +} from '../enums'; + +import { + getLanguagesFromNavigator, + mapBrowserTagToLocale, + resolveSupportedLocaleForBrowserTag, + getUserLocalesFromBrowserLanguages, + getNearestSupportedLocale, + sortSupportedLocalesByPreference, + pickDefaultLanguage, + orderCustomerLocalesForDisplay, +} from '../getUserLocalesFromBrowserLanguages'; + +const SUPPORTED_ALL: LocaleValue[] = Object.values( + CONSENT_MANAGER_SUPPORTED_LOCALES, +) as LocaleValue[]; + +describe('locale-helpers', () => { + describe('getLanguagesFromNavigator', () => { + it('uses navigator.languages when available and trims entries', () => { + const out = getLanguagesFromNavigator( + [' fr-CA ', 'en-US', ' '], + 'en-US', + ); + expect(out).to.deep.equal(['fr-CA', 'en-US']); + }); + + it('falls back to navigator.language when languages is empty/undefined', () => { + const out1 = getLanguagesFromNavigator([], 'de-DE'); + expect(out1).to.deep.equal(['de-DE']); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const out2 = getLanguagesFromNavigator(null as any, 'de-DE'); + expect(out2).to.deep.equal(['de-DE']); + }); + }); + + describe('mapBrowserTagToLocale (case-insensitive + base fallback)', () => { + it('maps exact tags regardless of case', () => { + expect(mapBrowserTagToLocale('fr-CA')).to.equal(LOCALE_KEY.FrCa); + expect(mapBrowserTagToLocale('FR-ca')).to.equal(LOCALE_KEY.FrCa); + expect(mapBrowserTagToLocale('En-US')).to.equal(LOCALE_KEY.EnUs); + }); + + it('falls back to base when the specific tag is not present', () => { + // no specific fr-XX -> use base mapping 'fr' + expect(mapBrowserTagToLocale('fr-XX')).to.equal(LOCALE_KEY.Fr); + // zh-hant-XX -> base is 'zh' (which maps to ZhCn in the browser map) + expect(mapBrowserTagToLocale('ZH-hant-XX')).to.equal(LOCALE_KEY.ZhCn); + }); + + it('returns undefined when neither exact nor base exist', () => { + expect(mapBrowserTagToLocale('zz-QQ')).to.equal(undefined); + }); + }); + + describe('resolveSupportedLocaleForBrowserTag', () => { + const SUPPORTED_DEMO: LocaleValue[] = [ + LOCALE_KEY.Ar, // base arabic + LOCALE_KEY.ArAe, // a variant + LOCALE_KEY.FrFr, + LOCALE_KEY.FrCa, + LOCALE_KEY.EnUs, + LOCALE_KEY.EnGb, + LOCALE_KEY.EsEs, + LOCALE_KEY.ZhCn, + LOCALE_KEY.ZhHk, + ]; + + it('returns mapped locale when it is supported', () => { + expect( + resolveSupportedLocaleForBrowserTag('fr-CA', SUPPORTED_DEMO), + ).to.equal(LOCALE_KEY.FrCa); + }); + + it('when mapped is unsupported, uses short base if it is supported', () => { + // base 'ar' is supported + expect( + resolveSupportedLocaleForBrowserTag('ar-EG', SUPPORTED_DEMO), + ).to.equal(LOCALE_KEY.Ar); + }); + + it('when short base not supported, picks first variant of same base (customer order respected)', () => { + const supported: LocaleValue[] = [ + LOCALE_KEY.ArAe, + LOCALE_KEY.FrFr, + LOCALE_KEY.EnUs, + ]; + expect(resolveSupportedLocaleForBrowserTag('ar-OM', supported)).to.equal( + LOCALE_KEY.ArAe, + ); + }); + + it('returns undefined if no base or variant is supported', () => { + const supported: LocaleValue[] = [LOCALE_KEY.FrFr, LOCALE_KEY.EnUs]; + expect(resolveSupportedLocaleForBrowserTag('ar-OM', supported)).to.equal( + undefined, + ); + }); + }); + + describe('getUserLocalesFromBrowserLanguages', () => { + it('produces ordered, unique list constrained by supported', () => { + const supported: LocaleValue[] = [ + LOCALE_KEY.Ar, + LOCALE_KEY.FrCa, + LOCALE_KEY.EnUs, + LOCALE_KEY.EsEs, + ]; + const browser = ['ar-EG', 'fr-CA', 'en-US', 'fr-CA']; // dup fr-CA + const res = getUserLocalesFromBrowserLanguages( + browser, + supported, + LOCALE_KEY.EnUs, + ); + expect(res).to.deep.equal([ + LOCALE_KEY.Ar, // base chosen + LOCALE_KEY.FrCa, // exact supported + LOCALE_KEY.EnUs, // exact supported + ]); + }); + + it('falls back to default when nothing matches', () => { + const res = getUserLocalesFromBrowserLanguages( + ['xx-YY'], + [LOCALE_KEY.FrFr], + LOCALE_KEY.EnUs, + ); + expect(res).to.deep.equal([LOCALE_KEY.EnUs]); + }); + + it('short beats variant if both are supported', () => { + const supported = [LOCALE_KEY.ArAe, LOCALE_KEY.Ar]; + const res = getUserLocalesFromBrowserLanguages( + ['ar-OM'], + supported, + LOCALE_KEY.EnUs, + ); + expect(res[0]).to.equal(LOCALE_KEY.Ar); + }); + }); + + describe('getNearestSupportedLocale', () => { + it('returns first preferred supported locale', () => { + const preferred = [LOCALE_KEY.FrCa, LOCALE_KEY.EnGb]; + const supported = [LOCALE_KEY.EnUs, LOCALE_KEY.FrCa]; + expect(getNearestSupportedLocale(preferred, supported)).to.equal( + LOCALE_KEY.FrCa, + ); + }); + + it('returns undefined when no match exists', () => { + const preferred = [LOCALE_KEY.EsEs]; + const supported = [LOCALE_KEY.EnUs]; + expect(getNearestSupportedLocale(preferred, supported)).to.equal( + undefined, + ); + }); + }); + + describe('sortSupportedLocalesByPreference', () => { + it('ranks exact > base-only > original order (stable)', () => { + const languages = [ + LOCALE_KEY.EnGb, + LOCALE_KEY.FrFr, + LOCALE_KEY.FrCa, + LOCALE_KEY.EnUs, + ] as LocaleValue[]; + + const preferred = [LOCALE_KEY.FrCa, LOCALE_KEY.En]; // exact fr-CA first, then any en-* + + const sorted = sortSupportedLocalesByPreference(languages, preferred); + + expect(sorted).to.deep.equal([ + LOCALE_KEY.FrCa, + LOCALE_KEY.FrFr, + LOCALE_KEY.EnGb, + LOCALE_KEY.EnUs, + ]); + }); + + it('preserves original order for non-matching locales', () => { + const languages = [LOCALE_KEY.EsEs, LOCALE_KEY.FrFr, LOCALE_KEY.EnUs]; + const preferred: LocaleValue[] = [LOCALE_KEY.ZhCn]; + const sorted = sortSupportedLocalesByPreference(languages, preferred); + expect(sorted).to.deep.equal(languages); + }); + }); + + describe('pickDefaultLanguage', () => { + it('tries same-prefix (zh or any zh-*) before fallback', () => { + const supported = [LOCALE_KEY.FrFr, LOCALE_KEY.ZhHk, LOCALE_KEY.EnUs]; + const picked = pickDefaultLanguage(supported, ['zh-HK'], LOCALE_KEY.EnUs); + expect(picked).to.equal(LOCALE_KEY.ZhHk); + }); + + it('falls back when no supported match exists (explicit test requested)', () => { + const supported = [LOCALE_KEY.FrFr]; + const picked = pickDefaultLanguage(supported, ['zh-HK'], LOCALE_KEY.EnUs); + expect(picked).to.equal(LOCALE_KEY.EnUs); + }); + + it('respects browser order across multiple tags', () => { + const supported = [LOCALE_KEY.FrFr, LOCALE_KEY.EnGb, LOCALE_KEY.EnUs]; + const picked = pickDefaultLanguage( + supported, + ['es-MX', 'en-GB', 'en-US'], + LOCALE_KEY.FrFr, + ); + expect(picked).to.equal(LOCALE_KEY.EnGb); + }); + }); + + describe('orderCustomerLocalesForDisplay', () => { + it('reorders customer list by user preference using the prefix rule', () => { + const customer = [ + LOCALE_KEY.FrFr, + LOCALE_KEY.EnUs, + LOCALE_KEY.ArAe, + LOCALE_KEY.FrCa, + ]; + const browser = ['fr-CA', 'ar-EG', 'en-US']; + + const ordered = orderCustomerLocalesForDisplay( + customer, + browser, + LOCALE_KEY.EnUs, + ); + + // fr-CA exact first, then first ar-* variant present, then en-US exact, then leftover fr-FR + expect(ordered.slice(0, 3)).to.deep.equal([ + LOCALE_KEY.FrCa, + LOCALE_KEY.ArAe, + LOCALE_KEY.EnUs, + ]); + expect(ordered[3]).to.equal(LOCALE_KEY.FrFr); + }); + + it('handles no matches by keeping original order (fallback not in list)', () => { + const customer = [LOCALE_KEY.FrFr, LOCALE_KEY.EnUs]; + const browser = ['xx-YY']; + const ordered = orderCustomerLocalesForDisplay( + customer, + browser, + LOCALE_KEY.ZhCn, // fallback NOT in customer list, so order should remain intact + ); + expect(ordered).to.deep.equal(customer); + }); + }); + + // + // Smoke test using the full supported set to ensure we don’t throw on large inputs. + // + describe('integration smoke against CONSENT_MANAGER_SUPPORTED_LOCALES', () => { + it('does not throw and returns something sensible', () => { + const browser = ['ES-mx', 'Fr-ca', 'en-GB', 'zh-HK']; + const result = getUserLocalesFromBrowserLanguages( + browser, + SUPPORTED_ALL, + LOCALE_KEY.EnUs, + ); + expect(result.length).to.be.greaterThan(0); + expect( + result.some( + (l) => + l === LOCALE_KEY.EsMx || + l === LOCALE_KEY.EsEs || // base fallback + l === LOCALE_KEY.FrCa || + l === LOCALE_KEY.Fr || // base fallback + l === LOCALE_KEY.EnGb || + l === LOCALE_KEY.EnUs || + l === LOCALE_KEY.ZhHk || + l === LOCALE_KEY.ZhCn, // base fallback + ), + ).to.equal(true); + }); + }); +}); From 7785628c6efd31b6ccca5877dc77ac33653ceb9e Mon Sep 17 00:00:00 2001 From: Michael Farrell Date: Fri, 10 Oct 2025 15:20:39 -0700 Subject: [PATCH 2/7] Update src/getUserLocalesFromBrowserLanguages.ts Co-authored-by: Cami <50341430+csmccarthy@users.noreply.github.com> --- src/getUserLocalesFromBrowserLanguages.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/getUserLocalesFromBrowserLanguages.ts b/src/getUserLocalesFromBrowserLanguages.ts index 5c85c19..57faf30 100644 --- a/src/getUserLocalesFromBrowserLanguages.ts +++ b/src/getUserLocalesFromBrowserLanguages.ts @@ -50,8 +50,8 @@ function uniqOrdered(items: T[]): T[] { export function getLanguagesFromNavigator( languages = navigator.languages, language = navigator.language, -): string[] { - const tags = (languages?.length ? languages : [language]) as string[]; +): BrowserLocaleKey[] { + const tags = (languages?.length ? languages : [language]) as BrowserLocaleKey[]; return tags.map((t) => t.trim()).filter((x) => !!x); } From edbb1cef93ad79a3916c80ea2a0902af414f5a2d Mon Sep 17 00:00:00 2001 From: michaelfarrell76 Date: Fri, 10 Oct 2025 15:21:02 -0700 Subject: [PATCH 3/7] ud --- src/getUserLocalesFromBrowserLanguages.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/getUserLocalesFromBrowserLanguages.ts b/src/getUserLocalesFromBrowserLanguages.ts index 57faf30..886f9b3 100644 --- a/src/getUserLocalesFromBrowserLanguages.ts +++ b/src/getUserLocalesFromBrowserLanguages.ts @@ -1,4 +1,8 @@ -import { LocaleValue, LOCALE_BROWSER_MAP_LOWERCASE } from './enums'; +import { + LocaleValue, + BrowserLocaleKey, + LOCALE_BROWSER_MAP_LOWERCASE, +} from './enums'; /** * Normalize a BCP-47 browser language tag to lowercase. @@ -51,7 +55,9 @@ export function getLanguagesFromNavigator( languages = navigator.languages, language = navigator.language, ): BrowserLocaleKey[] { - const tags = (languages?.length ? languages : [language]) as BrowserLocaleKey[]; + const tags = ( + languages?.length ? languages : [language] + ) as BrowserLocaleKey[]; return tags.map((t) => t.trim()).filter((x) => !!x); } From e444b81781664da043387731599f535b969a117b Mon Sep 17 00:00:00 2001 From: michaelfarrell76 Date: Fri, 10 Oct 2025 15:34:23 -0700 Subject: [PATCH 4/7] tsc --- src/getUserLocalesFromBrowserLanguages.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/getUserLocalesFromBrowserLanguages.ts b/src/getUserLocalesFromBrowserLanguages.ts index 886f9b3..da72682 100644 --- a/src/getUserLocalesFromBrowserLanguages.ts +++ b/src/getUserLocalesFromBrowserLanguages.ts @@ -55,10 +55,8 @@ 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); + const tags = languages?.length ? languages : [language]; + return tags.map((t) => t.trim()).filter((x) => !!x) as BrowserLocaleKey[]; } /** From fd3651e789fc303a3a3738b9e3b2fa1c54536dbe Mon Sep 17 00:00:00 2001 From: michaelfarrell76 Date: Fri, 10 Oct 2025 15:37:40 -0700 Subject: [PATCH 5/7] Another test --- src/tests/getUserLocalesFromBrowserLanguages.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/tests/getUserLocalesFromBrowserLanguages.test.ts b/src/tests/getUserLocalesFromBrowserLanguages.test.ts index c178166..5f71c77 100644 --- a/src/tests/getUserLocalesFromBrowserLanguages.test.ts +++ b/src/tests/getUserLocalesFromBrowserLanguages.test.ts @@ -97,6 +97,17 @@ describe('locale-helpers', () => { ); }); + it('when short base not supported, picks first variant of same base (customer order respected)', () => { + const supported: LocaleValue[] = [ + LOCALE_KEY.ArAe, + LOCALE_KEY.FrFr, + LOCALE_KEY.EnUs, + ]; + expect(resolveSupportedLocaleForBrowserTag('ar', supported)).to.equal( + LOCALE_KEY.ArAe, + ); + }); + it('returns undefined if no base or variant is supported', () => { const supported: LocaleValue[] = [LOCALE_KEY.FrFr, LOCALE_KEY.EnUs]; expect(resolveSupportedLocaleForBrowserTag('ar-OM', supported)).to.equal( From 73bc53e7b0fcc802f7ac81d91a9c79ca9198d736 Mon Sep 17 00:00:00 2001 From: michaelfarrell76 Date: Fri, 10 Oct 2025 16:38:50 -0700 Subject: [PATCH 6/7] Tests --- src/getUserLocalesFromBrowserLanguages.ts | 114 +++++++----------- ...getUserLocalesFromBrowserLanguages.test.ts | 98 ++++----------- 2 files changed, 63 insertions(+), 149 deletions(-) diff --git a/src/getUserLocalesFromBrowserLanguages.ts b/src/getUserLocalesFromBrowserLanguages.ts index da72682..26e0f90 100644 --- a/src/getUserLocalesFromBrowserLanguages.ts +++ b/src/getUserLocalesFromBrowserLanguages.ts @@ -59,91 +59,61 @@ export function getLanguagesFromNavigator( return tags.map((t) => t.trim()).filter((x) => !!x) as BrowserLocaleKey[]; } -/** - * 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]; - } - - // 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; - } - - // then first variant with same base - const variantMatch = supportedLocales.find( - (l) => l.includes('-') && baseOf(l).toLowerCase() === prefixLc, - ); - 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. + * De-duplicates and preserves order, but prioritizes **exact LOCALE_BROWSER_MAP hits** + * over fuzzy/base matches across the whole list. * * @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 + * @returns Ordered, unique supported LocaleValues with exact hits first */ 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 supportedSet = new Set(supportedLocales); + + const exact: LocaleValue[] = []; + const fuzzy: LocaleValue[] = []; + + // eslint-disable-next-line no-restricted-syntax + for (const tag of browserLocales) { + const lc = normalizeBrowserTag(tag); + + // 1) Exact LOCALE_BROWSER_MAP match (case-insensitive) + const direct = LOCALE_BROWSER_MAP_LOWERCASE[lc]; + if (direct && supportedSet.has(direct)) { + exact.push(direct); + // eslint-disable-next-line no-continue + continue; + } + + // 2) Fuzzy prefix rule against *supportedLocales*: + const prefix = baseOf(lc); + + // 2a) short/base code if supported + const short = supportedLocales.find((l) => l.toLowerCase() === prefix); + if (short) { + fuzzy.push(short); + // eslint-disable-next-line no-continue + continue; + } + + // 2b) otherwise first variant of same base in customer order + const variant = supportedLocales.find( + (l) => l.includes('-') && baseOf(l).toLowerCase() === prefix, + ); + if (variant) { + fuzzy.push(variant); + } + } - const unique = uniqOrdered(resolved); - return unique.length ? unique : [defaultLocale]; + // Exact hits outrank any fuzzy/base matches globally + const ordered = uniqOrdered([...exact, ...fuzzy]); + return ordered.length ? ordered : [defaultLocale]; } /** diff --git a/src/tests/getUserLocalesFromBrowserLanguages.test.ts b/src/tests/getUserLocalesFromBrowserLanguages.test.ts index 5f71c77..8044d47 100644 --- a/src/tests/getUserLocalesFromBrowserLanguages.test.ts +++ b/src/tests/getUserLocalesFromBrowserLanguages.test.ts @@ -8,8 +8,6 @@ import { import { getLanguagesFromNavigator, - mapBrowserTagToLocale, - resolveSupportedLocaleForBrowserTag, getUserLocalesFromBrowserLanguages, getNearestSupportedLocale, sortSupportedLocalesByPreference, @@ -41,81 +39,6 @@ describe('locale-helpers', () => { }); }); - describe('mapBrowserTagToLocale (case-insensitive + base fallback)', () => { - it('maps exact tags regardless of case', () => { - expect(mapBrowserTagToLocale('fr-CA')).to.equal(LOCALE_KEY.FrCa); - expect(mapBrowserTagToLocale('FR-ca')).to.equal(LOCALE_KEY.FrCa); - expect(mapBrowserTagToLocale('En-US')).to.equal(LOCALE_KEY.EnUs); - }); - - it('falls back to base when the specific tag is not present', () => { - // no specific fr-XX -> use base mapping 'fr' - expect(mapBrowserTagToLocale('fr-XX')).to.equal(LOCALE_KEY.Fr); - // zh-hant-XX -> base is 'zh' (which maps to ZhCn in the browser map) - expect(mapBrowserTagToLocale('ZH-hant-XX')).to.equal(LOCALE_KEY.ZhCn); - }); - - it('returns undefined when neither exact nor base exist', () => { - expect(mapBrowserTagToLocale('zz-QQ')).to.equal(undefined); - }); - }); - - describe('resolveSupportedLocaleForBrowserTag', () => { - const SUPPORTED_DEMO: LocaleValue[] = [ - LOCALE_KEY.Ar, // base arabic - LOCALE_KEY.ArAe, // a variant - LOCALE_KEY.FrFr, - LOCALE_KEY.FrCa, - LOCALE_KEY.EnUs, - LOCALE_KEY.EnGb, - LOCALE_KEY.EsEs, - LOCALE_KEY.ZhCn, - LOCALE_KEY.ZhHk, - ]; - - it('returns mapped locale when it is supported', () => { - expect( - resolveSupportedLocaleForBrowserTag('fr-CA', SUPPORTED_DEMO), - ).to.equal(LOCALE_KEY.FrCa); - }); - - it('when mapped is unsupported, uses short base if it is supported', () => { - // base 'ar' is supported - expect( - resolveSupportedLocaleForBrowserTag('ar-EG', SUPPORTED_DEMO), - ).to.equal(LOCALE_KEY.Ar); - }); - - it('when short base not supported, picks first variant of same base (customer order respected)', () => { - const supported: LocaleValue[] = [ - LOCALE_KEY.ArAe, - LOCALE_KEY.FrFr, - LOCALE_KEY.EnUs, - ]; - expect(resolveSupportedLocaleForBrowserTag('ar-OM', supported)).to.equal( - LOCALE_KEY.ArAe, - ); - }); - - it('when short base not supported, picks first variant of same base (customer order respected)', () => { - const supported: LocaleValue[] = [ - LOCALE_KEY.ArAe, - LOCALE_KEY.FrFr, - LOCALE_KEY.EnUs, - ]; - expect(resolveSupportedLocaleForBrowserTag('ar', supported)).to.equal( - LOCALE_KEY.ArAe, - ); - }); - - it('returns undefined if no base or variant is supported', () => { - const supported: LocaleValue[] = [LOCALE_KEY.FrFr, LOCALE_KEY.EnUs]; - expect(resolveSupportedLocaleForBrowserTag('ar-OM', supported)).to.equal( - undefined, - ); - }); - }); - describe('getUserLocalesFromBrowserLanguages', () => { it('produces ordered, unique list constrained by supported', () => { const supported: LocaleValue[] = [ @@ -146,6 +69,27 @@ describe('locale-helpers', () => { expect(res).to.deep.equal([LOCALE_KEY.EnUs]); }); + it('prioritizes a later exact LOCALE_BROWSER_MAP hit over an earlier fuzzy/base match', () => { + // Supported has both a base (Ar) and a specific variant (ArAe) + const supported: LocaleValue[] = [ + LOCALE_KEY.Ar, + LOCALE_KEY.ArAe, + LOCALE_KEY.EnUs, + ]; + + // Browser says a fuzzy base-match first (ar-OM -> Ar), then an exact map later (ar-AE -> ArAe) + const browser = ['ar-OM', 'ar-AE']; + + const res = getUserLocalesFromBrowserLanguages( + browser, + supported, + LOCALE_KEY.EnUs, + ); + + // Exact (ArAe) should be before fuzzy/base (Ar) + expect(res).to.deep.equal([LOCALE_KEY.ArAe, LOCALE_KEY.Ar]); + }); + it('short beats variant if both are supported', () => { const supported = [LOCALE_KEY.ArAe, LOCALE_KEY.Ar]; const res = getUserLocalesFromBrowserLanguages( From d4edab7c1ac892ccce5ee913d75d8168d0af8c7b Mon Sep 17 00:00:00 2001 From: michaelfarrell76 Date: Tue, 4 Nov 2025 16:01:16 -0800 Subject: [PATCH 7/7] ud --- src/enums.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/enums.ts b/src/enums.ts index 7154efa..21a1085 100644 --- a/src/enums.ts +++ b/src/enums.ts @@ -876,7 +876,7 @@ export const CONSENT_MANAGER_SUPPORTED_LOCALES = Object.fromEntries( * all other comments are to leave in those browser codes in case AWS updates to support them */ export const LOCALE_BROWSER_MAP = { - af: LOCALE_KEY.AfZz, // Afrikaans Afrikaans + af: LOCALE_KEY.Af, // Afrikaans Afrikaans 'af-NA': LOCALE_KEY.AfZz, // Afrikaans (Namibia) Afrikaans (Namibië) 'af-ZA': LOCALE_KEY.AfZz, // Afrikaans (South Africa) Afrikaans (Suid-Afrika) // 'agq', // Aghem Aghem @@ -1154,7 +1154,7 @@ export const LOCALE_BROWSER_MAP = { // 'ff-GN', // Fulah (Guinea) Pulaar (Gine) // 'ff-MR', // Fulah (Mauritania) Pulaar (Muritani) // 'ff-SN', // Fulah (Senegal) Pulaar (Senegaal) - fi: LOCALE_KEY.FiFi, // Finnish suomi + fi: LOCALE_KEY.Fi, // Finnish suomi 'fi-FI': LOCALE_KEY.FiFi, // Finnish (Finland) suomi (Suomi) fil: LOCALE_KEY.Fil, // Filipino Filipino 'fil-PH': LOCALE_KEY.FilPh, // Filipino (Philippines) Filipino (Pilipinas) @@ -1418,8 +1418,8 @@ export const LOCALE_BROWSER_MAP = { 'pl-PL': LOCALE_KEY.PlPl, // Polish (Poland) polski (Polska) ps: LOCALE_KEY.Ps, // Pashto پښتو 'ps-AF': LOCALE_KEY.PsAf, // Pashto (Afghanistan) پښتو (افغانستان) - pt: LOCALE_KEY.PtPt, // Portuguese português - 'pt-AO': LOCALE_KEY.PtPt, // Portuguese (Angola) português (Angola) + pt: LOCALE_KEY.Pt, // Portuguese português + 'pt-AO': LOCALE_KEY.Pt, // Portuguese (Angola) português (Angola) 'pt-BR': LOCALE_KEY.PtBr, // Portuguese (Brazil) português (Brasil) Brazilian Portuguese 'pt-CH': LOCALE_KEY.PtPt, // Portuguese (Switzerland) português (Suíça) 'pt-CV': LOCALE_KEY.PtPt, // Portuguese (Cape Verde) português (Cabo Verde) @@ -1596,7 +1596,7 @@ export const LOCALE_BROWSER_MAP = { 'zh-Hant-HK': LOCALE_KEY.ZhHk, // 中文(繁體字,中國香港特別行政區) Traditional Chinese (Hong Kong SAR China) 'zh-Hant-MO': LOCALE_KEY.ZhHk, // 中文(繁體字,中國澳門特別行政區) Traditional Chinese (Macau SAR China) 'zh-Hant-TW': LOCALE_KEY.ZhHk, // Chinese (Traditional, Taiwan) 中文(繁體,台灣) Traditional Chinese (Taiwan) - zu: LOCALE_KEY.ZuZa, // Zulu isiZulu + zu: LOCALE_KEY.Zu, // Zulu isiZulu 'zu-ZA': LOCALE_KEY.ZuZa, // Zulu (South Africa) isiZulu (iNingizimu Afrika) } as const satisfies Record;