Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 17 additions & 5 deletions src/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<string, LocaleValue>;

/** 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<string, LocaleValue>,
);

/**
* Native language names, used to render options to users
* Language options for end-users should be written in own language
Expand Down
213 changes: 213 additions & 0 deletions src/getUserLocalesFromBrowserLanguages.ts
Original file line number Diff line number Diff line change
@@ -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<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];
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<LocaleValue>([...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.
Copy link
Member

Choose a reason for hiding this comment

The 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);
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './enums';
export * from './types';
export * from './typeGuards';
export * from './defineMessages';
export * from './getUserLocalesFromBrowserLanguages';
Loading
Loading