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..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,13 +1596,25 @@ 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; /** 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..26e0f90 --- /dev/null +++ b/src/getUserLocalesFromBrowserLanguages.ts @@ -0,0 +1,213 @@ +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(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, +): BrowserLocaleKey[] { + const tags = languages?.length ? languages : [language]; + return tags.map((t) => t.trim()).filter((x) => !!x) as BrowserLocaleKey[]; +} + +/** + * Map an ordered list of browser tags to supported LocaleValues using the resolve rule. + * 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 with exact hits first + */ +export function getUserLocalesFromBrowserLanguages( + browserLocales: string[], + supportedLocales: LocaleValue[], + defaultLocale: LocaleValue, +): LocaleValue[] { + 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); + } + } + + // Exact hits outrank any fuzzy/base matches globally + const ordered = uniqOrdered([...exact, ...fuzzy]); + return ordered.length ? ordered : [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..8044d47 --- /dev/null +++ b/src/tests/getUserLocalesFromBrowserLanguages.test.ts @@ -0,0 +1,239 @@ +import { expect } from 'chai'; + +import { + LOCALE_KEY, + CONSENT_MANAGER_SUPPORTED_LOCALES, + LocaleValue, +} from '../enums'; + +import { + getLanguagesFromNavigator, + 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('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('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( + ['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); + }); + }); +});