managarten/packages/shared-i18n/src/utils.ts
2025-12-03 23:42:37 +01:00

290 lines
7.2 KiB
TypeScript

/**
* i18n utility functions
*/
import { getLanguageInfo } from './languages';
/**
* Detect user's preferred locale from browser
* Works in browser environment only
*/
export function detectBrowserLocale(
supportedLocales: readonly string[],
defaultLocale = 'en'
): string {
if (typeof navigator === 'undefined') {
return defaultLocale;
}
// Try navigator.language first
const browserLang = navigator.language;
// Check exact match (e.g., 'pt-BR')
if (supportedLocales.includes(browserLang)) {
return browserLang;
}
// Check base language (e.g., 'pt' from 'pt-BR')
const baseLang = browserLang.split('-')[0];
if (supportedLocales.includes(baseLang)) {
return baseLang;
}
// Try navigator.languages array
if (navigator.languages) {
for (const lang of navigator.languages) {
if (supportedLocales.includes(lang)) {
return lang;
}
const base = lang.split('-')[0];
if (supportedLocales.includes(base)) {
return base;
}
}
}
return defaultLocale;
}
/**
* Get locale from localStorage with validation
*/
export function getStoredLocale(
storageKey: string,
supportedLocales: readonly string[]
): string | null {
if (typeof localStorage === 'undefined') {
return null;
}
const stored = localStorage.getItem(storageKey);
if (stored && supportedLocales.includes(stored)) {
return stored;
}
return null;
}
/**
* Store locale in localStorage
*/
export function storeLocale(storageKey: string, locale: string): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(storageKey, locale);
}
/**
* Get initial locale with priority:
* 1. localStorage
* 2. Browser language
* 3. Default locale
*/
export function getInitialLocale(
storageKey: string,
supportedLocales: readonly string[],
defaultLocale = 'en'
): string {
// Check localStorage first
const stored = getStoredLocale(storageKey, supportedLocales);
if (stored) {
return stored;
}
// Fall back to browser language
return detectBrowserLocale(supportedLocales, defaultLocale);
}
/**
* Normalize locale code to standard format
* Examples: 'en-us' -> 'en-US', 'pt_BR' -> 'pt-BR'
*/
export function normalizeLocale(locale: string): string {
const parts = locale.replace('_', '-').split('-');
if (parts.length === 1) {
return parts[0].toLowerCase();
}
return `${parts[0].toLowerCase()}-${parts[1].toUpperCase()}`;
}
/**
* Get base language from locale code
* Examples: 'pt-BR' -> 'pt', 'en-US' -> 'en'
*/
export function getBaseLanguage(locale: string): string {
return locale.split('-')[0].toLowerCase();
}
/**
* Check if locale matches a language (including variants)
* Examples: matchesLanguage('pt-BR', 'pt') -> true
*/
export function matchesLanguage(locale: string, language: string): boolean {
const normalizedLocale = normalizeLocale(locale);
const normalizedLanguage = language.toLowerCase();
return (
normalizedLocale === normalizedLanguage ||
getBaseLanguage(normalizedLocale) === normalizedLanguage
);
}
/**
* Find best matching locale from supported list
*/
export function findBestMatch(
preferredLocale: string,
supportedLocales: readonly string[],
defaultLocale = 'en'
): string {
const normalized = normalizeLocale(preferredLocale);
// Exact match
if (supportedLocales.includes(normalized)) {
return normalized;
}
// Base language match
const base = getBaseLanguage(normalized);
if (supportedLocales.includes(base)) {
return base;
}
// Find any variant of the same language
const variant = supportedLocales.find((loc) => getBaseLanguage(loc) === base);
if (variant) {
return variant;
}
return defaultLocale;
}
/**
* Format number according to locale
*/
export function formatLocalizedNumber(
value: number,
locale = 'en',
options?: Intl.NumberFormatOptions
): string {
return new Intl.NumberFormat(locale, options).format(value);
}
/**
* Format date according to locale
*/
export function formatLocalizedDate(
date: Date | string | number,
locale = 'en',
options?: Intl.DateTimeFormatOptions
): string {
const dateObj = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat(locale, options).format(dateObj);
}
/**
* Format relative time according to locale
*/
export function formatRelativeTime(
date: Date | string | number,
locale = 'en',
style: 'long' | 'short' | 'narrow' = 'long'
): string {
const dateObj = date instanceof Date ? date : new Date(date);
const now = new Date();
const diffMs = dateObj.getTime() - now.getTime();
const diffSecs = Math.round(diffMs / 1000);
const diffMins = Math.round(diffSecs / 60);
const diffHours = Math.round(diffMins / 60);
const diffDays = Math.round(diffHours / 24);
const diffWeeks = Math.round(diffDays / 7);
const diffMonths = Math.round(diffDays / 30);
const diffYears = Math.round(diffDays / 365);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style });
if (Math.abs(diffSecs) < 60) {
return rtf.format(diffSecs, 'second');
} else if (Math.abs(diffMins) < 60) {
return rtf.format(diffMins, 'minute');
} else if (Math.abs(diffHours) < 24) {
return rtf.format(diffHours, 'hour');
} else if (Math.abs(diffDays) < 7) {
return rtf.format(diffDays, 'day');
} else if (Math.abs(diffWeeks) < 4) {
return rtf.format(diffWeeks, 'week');
} else if (Math.abs(diffMonths) < 12) {
return rtf.format(diffMonths, 'month');
} else {
return rtf.format(diffYears, 'year');
}
}
/**
* Get plural form category
*/
export function getPluralCategory(count: number, locale = 'en'): Intl.LDMLPluralRule {
const pr = new Intl.PluralRules(locale);
return pr.select(count);
}
/**
* Interpolate values into a translation string
* Example: interpolate("Hello {name}!", { name: "World" }) -> "Hello World!"
*/
export function interpolate(text: string, values: Record<string, string | number>): string {
return text.replace(/\{(\w+)\}/g, (match, key) => {
return key in values ? String(values[key]) : match;
});
}
/**
* Interface for PillDropdown items (matches shared-ui type)
*/
export interface LanguageDropdownItem {
id: string;
label: string;
icon?: string;
onClick: () => void;
active?: boolean;
}
/**
* Create PillDropdown items for language selection
* @param supportedLocales - Array of supported locale codes (e.g., ['de', 'en', 'it', 'fr', 'es'])
* @param currentLocale - Currently selected locale
* @param onLocaleChange - Callback when locale changes
* @returns Array of items compatible with PillDropdown
*/
export function getLanguageDropdownItems(
supportedLocales: readonly string[],
currentLocale: string,
onLocaleChange: (locale: string) => void
): LanguageDropdownItem[] {
return supportedLocales.map((locale) => {
const info = getLanguageInfo(locale);
return {
id: locale,
label: info ? `${info.emoji} ${info.nativeName}` : locale.toUpperCase(),
onClick: () => onLocaleChange(locale),
active: currentLocale === locale,
};
});
}
/**
* Get current language label for PillDropdown trigger
* @param currentLocale - Currently selected locale
* @returns Label with flag emoji and native name (e.g., "🇩🇪 Deutsch")
*/
export function getCurrentLanguageLabel(currentLocale: string): string {
const info = getLanguageInfo(currentLocale);
if (info) {
return `${info.emoji} ${info.nativeName}`;
}
return currentLocale.toUpperCase();
}