336 lines
10 KiB
TypeScript
336 lines
10 KiB
TypeScript
import EN_TRANSLATIONS from './locales/en.json'
|
|
|
|
export const DEFAULT_APP_LOCALE = 'en'
|
|
export const SYSTEM_UI_LANGUAGE = 'system'
|
|
|
|
export const APP_LOCALES = [
|
|
'en',
|
|
'it-IT',
|
|
'fr-FR',
|
|
'de-DE',
|
|
'ru-RU',
|
|
'es-ES',
|
|
'pt-BR',
|
|
'pt-PT',
|
|
'es-419',
|
|
'zh-CN',
|
|
'zh-TW',
|
|
'ja-JP',
|
|
'ko-KR',
|
|
'vi',
|
|
'pl-PL',
|
|
'be-BY',
|
|
'be-Latn',
|
|
'id-ID',
|
|
] as const
|
|
|
|
export type AppLocale = typeof APP_LOCALES[number]
|
|
export type UiLanguagePreference = typeof SYSTEM_UI_LANGUAGE | AppLocale
|
|
export type TranslationCatalog = typeof EN_TRANSLATIONS
|
|
export type TranslationKey = keyof TranslationCatalog
|
|
export type TranslationValues = Record<string, string | number>
|
|
|
|
type LocaleDefinition = {
|
|
code: AppLocale
|
|
dateLocale: string
|
|
labelKey: TranslationKey
|
|
aliases: readonly string[]
|
|
searchKeywords: readonly string[]
|
|
}
|
|
|
|
const LOCALE_DEFINITIONS: Record<AppLocale, LocaleDefinition> = {
|
|
en: {
|
|
code: 'en',
|
|
dateLocale: 'en-US',
|
|
labelKey: 'locale.en',
|
|
aliases: ['en', 'en-us', 'en-gb', 'en-ca', 'en-au'],
|
|
searchKeywords: ['english', 'en'],
|
|
},
|
|
'it-IT': {
|
|
code: 'it-IT',
|
|
dateLocale: 'it-IT',
|
|
labelKey: 'locale.itIT',
|
|
aliases: ['it', 'it-it'],
|
|
searchKeywords: ['italian', 'italiano', 'it', 'it-it'],
|
|
},
|
|
'fr-FR': {
|
|
code: 'fr-FR',
|
|
dateLocale: 'fr-FR',
|
|
labelKey: 'locale.frFR',
|
|
aliases: ['fr', 'fr-fr'],
|
|
searchKeywords: ['french', 'francais', 'français', 'fr', 'fr-fr'],
|
|
},
|
|
'de-DE': {
|
|
code: 'de-DE',
|
|
dateLocale: 'de-DE',
|
|
labelKey: 'locale.deDE',
|
|
aliases: ['de', 'de-de'],
|
|
searchKeywords: ['german', 'deutsch', 'de', 'de-de'],
|
|
},
|
|
'ru-RU': {
|
|
code: 'ru-RU',
|
|
dateLocale: 'ru-RU',
|
|
labelKey: 'locale.ruRU',
|
|
aliases: ['ru', 'ru-ru'],
|
|
searchKeywords: ['russian', 'russkiy', 'русский', 'ru', 'ru-ru'],
|
|
},
|
|
'es-ES': {
|
|
code: 'es-ES',
|
|
dateLocale: 'es-ES',
|
|
labelKey: 'locale.esES',
|
|
aliases: ['es-es'],
|
|
searchKeywords: ['spanish', 'espanol', 'español', 'spain', 'es', 'es-es'],
|
|
},
|
|
'pt-BR': {
|
|
code: 'pt-BR',
|
|
dateLocale: 'pt-BR',
|
|
labelKey: 'locale.ptBR',
|
|
aliases: ['pt-br'],
|
|
searchKeywords: ['portuguese', 'brasil', 'brazilian', 'pt', 'pt-br'],
|
|
},
|
|
'pt-PT': {
|
|
code: 'pt-PT',
|
|
dateLocale: 'pt-PT',
|
|
labelKey: 'locale.ptPT',
|
|
aliases: ['pt-pt'],
|
|
searchKeywords: ['portuguese', 'portugal', 'european', 'pt-pt'],
|
|
},
|
|
'es-419': {
|
|
code: 'es-419',
|
|
dateLocale: 'es-419',
|
|
labelKey: 'locale.es419',
|
|
aliases: [
|
|
'es-419',
|
|
'es-ar',
|
|
'es-bo',
|
|
'es-cl',
|
|
'es-co',
|
|
'es-cr',
|
|
'es-cu',
|
|
'es-do',
|
|
'es-ec',
|
|
'es-gt',
|
|
'es-hn',
|
|
'es-mx',
|
|
'es-ni',
|
|
'es-pa',
|
|
'es-pe',
|
|
'es-pr',
|
|
'es-py',
|
|
'es-sv',
|
|
'es-us',
|
|
'es-uy',
|
|
'es-ve',
|
|
],
|
|
searchKeywords: ['spanish', 'latin', 'latam', 'latin america', 'es-419'],
|
|
},
|
|
'zh-CN': {
|
|
code: 'zh-CN',
|
|
dateLocale: 'zh-CN',
|
|
labelKey: 'locale.zhCN',
|
|
aliases: ['zh', 'zh-cn', 'zh-hans', 'zh-sg'],
|
|
searchKeywords: ['chinese', 'simplified', 'zh', 'zh-cn', '中文', '简体中文'],
|
|
},
|
|
'zh-TW': {
|
|
code: 'zh-TW',
|
|
dateLocale: 'zh-TW',
|
|
labelKey: 'locale.zhTW',
|
|
aliases: ['zh-tw', 'zh-hant', 'zh-hk', 'zh-mo'],
|
|
searchKeywords: ['chinese', 'traditional', 'zh-tw', 'zh-hant', '中文', '繁體中文', '繁体中文'],
|
|
},
|
|
'ja-JP': {
|
|
code: 'ja-JP',
|
|
dateLocale: 'ja-JP',
|
|
labelKey: 'locale.jaJP',
|
|
aliases: ['ja', 'ja-jp'],
|
|
searchKeywords: ['japanese', 'nihongo', '日本語', 'ja', 'ja-jp'],
|
|
},
|
|
'ko-KR': {
|
|
code: 'ko-KR',
|
|
dateLocale: 'ko-KR',
|
|
labelKey: 'locale.koKR',
|
|
aliases: ['ko', 'ko-kr'],
|
|
searchKeywords: ['korean', 'hangul', '한국어', 'ko', 'ko-kr'],
|
|
},
|
|
vi: {
|
|
code: 'vi',
|
|
dateLocale: 'vi-VN',
|
|
labelKey: 'locale.vi',
|
|
aliases: ['vi', 'vi-vn'],
|
|
searchKeywords: ['vietnamese', 'vietnam', 'viet nam', 'tiếng việt', 'tieng viet', 'việt nam', 'vi'],
|
|
},
|
|
'pl-PL': {
|
|
code: 'pl-PL',
|
|
dateLocale: 'pl-PL',
|
|
labelKey: 'locale.plPL',
|
|
aliases: ['pl', 'pl-pl'],
|
|
searchKeywords: ['polish', 'polski', 'polska', 'pl', 'pl-pl'],
|
|
},
|
|
'be-BY': {
|
|
code: 'be-BY',
|
|
dateLocale: 'be-BY',
|
|
labelKey: 'locale.beBY',
|
|
aliases: ['be', 'be-by'],
|
|
searchKeywords: ['belarusian', 'беларуская', 'be', 'be-by'],
|
|
},
|
|
'be-Latn': {
|
|
code: 'be-Latn',
|
|
dateLocale: 'be-Latn',
|
|
labelKey: 'locale.beLatn',
|
|
aliases: ['be-latn'],
|
|
searchKeywords: ['belarusian', 'bielaruskaja', 'lacinka', 'be-latn'],
|
|
},
|
|
'id-ID': {
|
|
code: 'id-ID',
|
|
dateLocale: 'id-ID',
|
|
labelKey: 'locale.idID',
|
|
aliases: ['id','id-id'],
|
|
searchKeywords: ['indonesia', 'indonesian', 'bahasa', 'idn', 'id', 'id-id'],
|
|
},
|
|
}
|
|
|
|
const APP_LOCALE_SET = new Set<AppLocale>(APP_LOCALES)
|
|
const LOCALE_DEFINITION_LOOKUP = new Map<AppLocale, LocaleDefinition>(
|
|
Object.values(LOCALE_DEFINITIONS).map((definition) => [definition.code, definition]),
|
|
)
|
|
const NORMALIZED_LOCALE_LOOKUP = new Map<string, AppLocale>()
|
|
for (const locale of APP_LOCALES) {
|
|
const definition = getLocaleDefinition(locale)
|
|
NORMALIZED_LOCALE_LOOKUP.set(locale.toLowerCase(), locale)
|
|
for (const alias of definition.aliases) {
|
|
NORMALIZED_LOCALE_LOOKUP.set(alias, locale)
|
|
}
|
|
}
|
|
|
|
const LOCALE_MODULES = import.meta.glob('./locales/*.json', { eager: true, import: 'default' }) as Record<string, TranslationCatalog>
|
|
const TRANSLATIONS: Partial<Record<AppLocale, Partial<Record<TranslationKey, string>>>> = buildTranslations()
|
|
|
|
export const APP_LOCALE_DEFINITIONS = APP_LOCALES.map((locale) => getLocaleDefinition(locale))
|
|
export { EN_TRANSLATIONS }
|
|
|
|
function buildTranslations() {
|
|
const translations: Partial<Record<AppLocale, Partial<Record<TranslationKey, string>>>> = {
|
|
en: EN_TRANSLATIONS,
|
|
}
|
|
|
|
for (const [path, catalog] of Object.entries(LOCALE_MODULES)) {
|
|
const match = path.match(/\/([^/]+)\.json$/)
|
|
if (!match) continue
|
|
|
|
const locale = normalizeLocaleCode(match[1])
|
|
if (!locale || locale === 'en') continue
|
|
|
|
Reflect.set(translations, locale, catalog)
|
|
}
|
|
|
|
return translations
|
|
}
|
|
|
|
function isAppLocale(value: string): value is AppLocale {
|
|
return APP_LOCALE_SET.has(value as AppLocale)
|
|
}
|
|
|
|
export function getLocaleDefinition(locale: AppLocale): LocaleDefinition {
|
|
const definition = LOCALE_DEFINITION_LOOKUP.get(locale)
|
|
if (definition) return definition
|
|
throw new Error(`Unknown locale: ${locale}`)
|
|
}
|
|
|
|
export function getLocaleDateLocale(locale: AppLocale): string {
|
|
return getLocaleDefinition(locale).dateLocale
|
|
}
|
|
|
|
export function interpolate(template: string, values: TranslationValues = {}): string {
|
|
const interpolationValues = new Map(Object.entries(values))
|
|
return template.replace(/\{(\w+)\}/g, (match, key) => {
|
|
const value = interpolationValues.get(key)
|
|
return value === undefined ? match : String(value)
|
|
})
|
|
}
|
|
|
|
function localizedInterpolationValues(locale: AppLocale, values?: TranslationValues): TranslationValues | undefined {
|
|
if (!values || locale === 'en' || values.plural === undefined) return values
|
|
return { ...values, plural: '' }
|
|
}
|
|
|
|
export function translate(locale: AppLocale, key: TranslationKey, values?: TranslationValues): string {
|
|
const catalog = Reflect.get(TRANSLATIONS, locale) as Partial<Record<TranslationKey, string>> | undefined
|
|
const template = Reflect.get(catalog ?? {}, key) as string | undefined
|
|
const fallbackTemplate = Reflect.get(EN_TRANSLATIONS, key) as string
|
|
return interpolate(template ?? fallbackTemplate, localizedInterpolationValues(locale, values))
|
|
}
|
|
|
|
export function createTranslator(locale: AppLocale = DEFAULT_APP_LOCALE) {
|
|
return (key: TranslationKey, values?: TranslationValues) => translate(locale, key, values)
|
|
}
|
|
|
|
function normalizeLocaleCode(value: string): AppLocale | null {
|
|
const normalized = value.trim().replaceAll('_', '-').toLowerCase()
|
|
if (!normalized) return null
|
|
|
|
const exactMatch = NORMALIZED_LOCALE_LOOKUP.get(normalized)
|
|
if (exactMatch) return exactMatch
|
|
|
|
const languageMatches = APP_LOCALES.filter((locale) => locale.toLowerCase().startsWith(`${normalized}-`))
|
|
return languageMatches.length === 1 ? languageMatches[0] : null
|
|
}
|
|
|
|
export function normalizeUiLanguagePreference(value: unknown): UiLanguagePreference | null {
|
|
if (typeof value !== 'string') return null
|
|
const trimmed = value.trim()
|
|
if (!trimmed) return null
|
|
const lower = trimmed.toLowerCase()
|
|
if (lower === SYSTEM_UI_LANGUAGE || lower === 'auto') return SYSTEM_UI_LANGUAGE
|
|
return normalizeLocaleCode(trimmed)
|
|
}
|
|
|
|
export function serializeUiLanguagePreference(value: unknown): AppLocale | null {
|
|
const normalized = normalizeUiLanguagePreference(value)
|
|
if (!normalized || normalized === SYSTEM_UI_LANGUAGE) return null
|
|
return normalized
|
|
}
|
|
|
|
export function getBrowserLanguagePreferences(): string[] {
|
|
if (typeof navigator === 'undefined') return []
|
|
const languages = Array.isArray(navigator.languages) ? navigator.languages : []
|
|
if (languages.length > 0) return [...languages]
|
|
return navigator.language ? [navigator.language] : []
|
|
}
|
|
|
|
export function resolveEffectiveLocale(
|
|
preference: unknown,
|
|
languagePreferences: readonly string[] = getBrowserLanguagePreferences(),
|
|
): AppLocale {
|
|
const normalizedPreference = normalizeUiLanguagePreference(preference)
|
|
if (normalizedPreference && normalizedPreference !== SYSTEM_UI_LANGUAGE) {
|
|
return normalizedPreference
|
|
}
|
|
|
|
for (const language of languagePreferences) {
|
|
const locale = normalizeLocaleCode(language)
|
|
if (locale) return locale
|
|
}
|
|
|
|
return DEFAULT_APP_LOCALE
|
|
}
|
|
|
|
export function localeDisplayName(locale: AppLocale, displayLocale: AppLocale = locale): string {
|
|
return translate(displayLocale, getLocaleDefinition(locale).labelKey)
|
|
}
|
|
|
|
export function localeSearchKeywords(locale: AppLocale): readonly string[] {
|
|
return getLocaleDefinition(locale).searchKeywords
|
|
}
|
|
|
|
export function hasLocaleCatalog(locale: AppLocale): boolean {
|
|
return locale === 'en' || Boolean(Reflect.get(TRANSLATIONS, locale))
|
|
}
|
|
|
|
export function localeCatalogLocales(): AppLocale[] {
|
|
return APP_LOCALES.filter((locale) => hasLocaleCatalog(locale))
|
|
}
|
|
|
|
export function isCanonicalAppLocale(value: string): value is AppLocale {
|
|
return isAppLocale(value)
|
|
}
|