mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-29 02:41:26 +00:00
⚙️ refactor: Lazy load locale resources (#13640)
This commit is contained in:
parent
56608739f8
commit
db863e75e3
13 changed files with 651 additions and 167 deletions
7
client/src/@types/i18next.d.ts
vendored
7
client/src/@types/i18next.d.ts
vendored
|
|
@ -1,9 +1,12 @@
|
|||
import { defaultNS, resources } from '~/locales/i18n';
|
||||
import translationEn from '~/locales/en/translation.json';
|
||||
import { defaultNS } from '~/locales/i18n';
|
||||
|
||||
declare module 'i18next' {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: typeof defaultNS;
|
||||
resources: typeof resources.en;
|
||||
resources: {
|
||||
translation: typeof translationEn;
|
||||
};
|
||||
strictKeyChecks: true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
|
|||
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
|
||||
import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
|
||||
import WakeLockManager from '~/components/System/WakeLockManager';
|
||||
import LanguageSync from '~/components/System/LanguageSync';
|
||||
import { getThemeFromEnv } from './utils/getThemeFromEnv';
|
||||
import { initializeFontSize } from '~/store/fontSize';
|
||||
import { LiveAnnouncer } from '~/a11y';
|
||||
|
|
@ -47,6 +48,7 @@ const App = () => {
|
|||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RecoilRoot>
|
||||
<LanguageSync />
|
||||
<LiveAnnouncer>
|
||||
<ThemeProvider
|
||||
// Only pass initialTheme and themeRGB if environment theme exists
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useContext, useCallback } from 'react';
|
||||
import Cookies from 'js-cookie';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Dropdown, ThemeContext } from '@librechat/client';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { Dropdown, Spinner, ThemeContext } from '@librechat/client';
|
||||
import ArchivedChats from './ArchivedChats';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
|
@ -85,6 +85,7 @@ export const LangSelector = ({
|
|||
portal?: boolean;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const isLanguageLoading = useRecoilValue(store.languageLoading);
|
||||
|
||||
const languageOptions = [
|
||||
{ value: 'auto', label: localize('com_nav_lang_auto') },
|
||||
|
|
@ -137,15 +138,26 @@ export const LangSelector = ({
|
|||
<div className="flex items-center justify-between">
|
||||
<div id={labelId}>{localize('com_nav_language')}</div>
|
||||
|
||||
<Dropdown
|
||||
value={langcode}
|
||||
onChange={onChange}
|
||||
sizeClasses="[--anchor-max-height:256px] max-h-[60vh]"
|
||||
options={languageOptions}
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
portal={portal}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLanguageLoading && (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={localize('com_ui_loading')}
|
||||
className="flex size-5 items-center justify-center text-text-secondary"
|
||||
>
|
||||
<Spinner className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
<Dropdown
|
||||
value={langcode}
|
||||
onChange={onChange}
|
||||
sizeClasses="[--anchor-max-height:256px] max-h-[60vh]"
|
||||
options={languageOptions}
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
portal={portal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -169,9 +181,6 @@ function General() {
|
|||
userLang = navigator.language || navigator.languages[0];
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
document.documentElement.lang = userLang;
|
||||
});
|
||||
setLangcode(userLang);
|
||||
Cookies.set('lang', userLang, { expires: 365 });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import 'test/matchMedia.mock';
|
|||
import React from 'react';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { LangSelector } from './General';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { LangSelector } from './General';
|
||||
import store from '~/store';
|
||||
|
||||
describe('LangSelector', () => {
|
||||
let mockOnChange;
|
||||
|
|
@ -54,4 +55,19 @@ describe('LangSelector', () => {
|
|||
expect(mockOnChange).toHaveBeenCalledWith('it-IT');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a loading indicator while language resources load', () => {
|
||||
global.ResizeObserver = class MockedResizeObserver {
|
||||
observe = jest.fn();
|
||||
unobserve = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
};
|
||||
const { getByRole } = render(
|
||||
<RecoilRoot initializeState={({ set }) => set(store.languageLoading, true)}>
|
||||
<LangSelector langcode="en-US" onChange={mockOnChange} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
expect(getByRole('status', { name: 'Loading...' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,11 +17,11 @@ import {
|
|||
OGDialogTrigger,
|
||||
} from '@librechat/client';
|
||||
import { ThemeSelector, LangSelector } from '~/components/Nav/SettingsTabs/General/General';
|
||||
import { ShareMessagesProvider } from './ShareMessagesProvider';
|
||||
import { ShareArtifactsContainer } from './ShareArtifacts';
|
||||
import { useLocalize, useDocumentTitle } from '~/hooks';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { ShareContext } from '~/Providers';
|
||||
import { ShareMessagesProvider } from './ShareMessagesProvider';
|
||||
import MessagesView from './MessagesView';
|
||||
import Footer from '../Chat/Footer';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -80,10 +80,6 @@ function SharedView() {
|
|||
: null) ?? 'en-US';
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
document.documentElement.lang = userLang;
|
||||
});
|
||||
|
||||
setLangcode(userLang);
|
||||
Cookies.set('lang', userLang, { expires: 365 });
|
||||
},
|
||||
|
|
|
|||
35
client/src/components/System/LanguageSync.tsx
Normal file
35
client/src/components/System/LanguageSync.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import i18n, { changeLanguageSafely, normalizeLocale } from '~/locales/i18n';
|
||||
import store from '~/store';
|
||||
|
||||
export default function LanguageSync() {
|
||||
const lang = useRecoilValue(store.lang);
|
||||
const setLanguageLoading = useSetRecoilState(store.languageLoading);
|
||||
|
||||
useEffect(() => {
|
||||
if (i18n.language === normalizeLocale(lang)) {
|
||||
setLanguageLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let isCurrentRequest = true;
|
||||
setLanguageLoading(true);
|
||||
|
||||
changeLanguageSafely(lang)
|
||||
.catch((error) => {
|
||||
console.error('[i18n] Failed to change language', error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrentRequest) {
|
||||
setLanguageLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCurrentRequest = false;
|
||||
};
|
||||
}, [lang, setLanguageLoading]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,21 +1,12 @@
|
|||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { TOptions } from 'i18next';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { resources } from '~/locales/i18n';
|
||||
import store from '~/store';
|
||||
import translationEn from '~/locales/en/translation.json';
|
||||
|
||||
export type TranslationKeys = keyof typeof resources.en.translation;
|
||||
export type TranslationKeys = keyof typeof translationEn;
|
||||
|
||||
export default function useLocalize() {
|
||||
const lang = useRecoilValue(store.lang);
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (i18n.language !== lang) {
|
||||
i18n.changeLanguage(lang);
|
||||
}
|
||||
}, [lang, i18n]);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useCallback(
|
||||
(phraseKey: TranslationKeys, options?: TOptions) => t(phraseKey, options),
|
||||
|
|
|
|||
|
|
@ -1,48 +1,193 @@
|
|||
import i18n from './i18n';
|
||||
import type { TranslationResource } from './i18n';
|
||||
import {
|
||||
__resetLocaleForTests,
|
||||
__setLocaleLoaderForTests,
|
||||
changeLanguageSafely,
|
||||
ensureLocale,
|
||||
initializeI18n,
|
||||
normalizeLocale,
|
||||
} from './i18n';
|
||||
import English from './en/translation.json';
|
||||
import French from './fr/translation.json';
|
||||
import Spanish from './es/translation.json';
|
||||
import French from './fr/translation.json';
|
||||
import { TranslationKeys } from '~/hooks';
|
||||
import i18n from './i18n';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('i18next translation tests', () => {
|
||||
// Ensure i18next is initialized before any tests run
|
||||
beforeAll(async () => {
|
||||
if (!i18n.isInitialized) {
|
||||
await i18n.init();
|
||||
}
|
||||
await initializeI18n();
|
||||
});
|
||||
|
||||
it('should return the correct translation for a valid key in English', () => {
|
||||
i18n.changeLanguage('en');
|
||||
afterEach(async () => {
|
||||
await changeLanguageSafely('en');
|
||||
});
|
||||
|
||||
it('should return the correct translation for a valid key in English', async () => {
|
||||
await changeLanguageSafely('en');
|
||||
expect(i18n.t('com_ui_examples')).toBe(English.com_ui_examples);
|
||||
});
|
||||
|
||||
it('should return the correct translation for a valid key in French', () => {
|
||||
i18n.changeLanguage('fr');
|
||||
it('should return the correct translation for a valid key in French', async () => {
|
||||
await changeLanguageSafely('fr');
|
||||
expect(i18n.t('com_ui_examples')).toBe(French.com_ui_examples);
|
||||
});
|
||||
|
||||
it('should return the correct translation for a valid key in Spanish', () => {
|
||||
i18n.changeLanguage('es');
|
||||
it('should return the correct translation for a valid key in Spanish', async () => {
|
||||
await changeLanguageSafely('es');
|
||||
expect(i18n.t('com_ui_examples')).toBe(Spanish.com_ui_examples);
|
||||
});
|
||||
|
||||
it('should fallback to English for an invalid language code', () => {
|
||||
it('should fallback to English for an invalid language code', async () => {
|
||||
// When an invalid language is provided, i18next should fallback to English
|
||||
i18n.changeLanguage('invalid-code');
|
||||
await changeLanguageSafely('invalid-code');
|
||||
expect(i18n.t('com_ui_examples')).toBe(English.com_ui_examples);
|
||||
});
|
||||
|
||||
it('should return the key itself for an invalid key', () => {
|
||||
i18n.changeLanguage('en');
|
||||
it('should return the key itself for an invalid key', async () => {
|
||||
await changeLanguageSafely('en');
|
||||
expect(i18n.t('invalid-key' as TranslationKeys)).toBe('invalid-key'); // Returns the key itself
|
||||
});
|
||||
|
||||
it('should correctly format placeholders in the translation', () => {
|
||||
i18n.changeLanguage('en');
|
||||
it('should correctly format placeholders in the translation', async () => {
|
||||
await changeLanguageSafely('en');
|
||||
expect(i18n.t('com_endpoint_default_with_num', { 0: 'John' })).toBe('default: John');
|
||||
|
||||
i18n.changeLanguage('fr');
|
||||
await changeLanguageSafely('fr');
|
||||
expect(i18n.t('com_endpoint_default_with_num', { 0: 'Marie' })).toBe('par défaut : Marie');
|
||||
});
|
||||
|
||||
it('should normalize language selector values to locale files', () => {
|
||||
expect(normalizeLocale('en-US')).toBe('en');
|
||||
expect(normalizeLocale('de-DE')).toBe('de');
|
||||
expect(normalizeLocale('fr-FR')).toBe('fr');
|
||||
expect(normalizeLocale('ar-EG')).toBe('ar');
|
||||
expect(normalizeLocale('he-IL')).toBe('he');
|
||||
expect(normalizeLocale('nl-NL')).toBe('nl');
|
||||
expect(normalizeLocale('pl-PL')).toBe('pl');
|
||||
expect(normalizeLocale('uk-UA')).toBe('uk');
|
||||
expect(normalizeLocale('zh-Hans')).toBe('zh-Hans');
|
||||
expect(normalizeLocale('zh-Hant')).toBe('zh-Hant');
|
||||
expect(normalizeLocale('pt-BR')).toBe('pt-BR');
|
||||
expect(normalizeLocale('pt-PT')).toBe('pt-PT');
|
||||
});
|
||||
|
||||
it('should reuse an in-flight locale load', async () => {
|
||||
__resetLocaleForTests('sv');
|
||||
const pendingLocale = deferred<{ default: TranslationResource }>();
|
||||
const loadLocale = jest.fn(() => pendingLocale.promise);
|
||||
const restoreLoader = __setLocaleLoaderForTests('sv', loadLocale);
|
||||
|
||||
const firstLoad = ensureLocale('sv-SE');
|
||||
const secondLoad = ensureLocale('sv-SE');
|
||||
|
||||
expect(loadLocale).toHaveBeenCalledTimes(1);
|
||||
|
||||
pendingLocale.resolve({ default: { com_ui_examples: 'svenska exempel' } });
|
||||
|
||||
await expect(Promise.all([firstLoad, secondLoad])).resolves.toEqual(['sv', 'sv']);
|
||||
expect(i18n.getResource('sv', 'translation', 'com_ui_examples')).toBe('svenska exempel');
|
||||
|
||||
restoreLoader();
|
||||
__resetLocaleForTests('sv');
|
||||
});
|
||||
|
||||
it('should retry a locale load after a transient failure', async () => {
|
||||
__resetLocaleForTests('ka');
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
let callCount = 0;
|
||||
const restoreLoader = __setLocaleLoaderForTests('ka', async () => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
throw new Error('temporary chunk failure');
|
||||
}
|
||||
|
||||
return { default: { com_ui_examples: 'ქართული მაგალითები' } };
|
||||
});
|
||||
|
||||
await expect(ensureLocale('ka-GE')).resolves.toBe('en');
|
||||
await expect(ensureLocale('ka-GE')).resolves.toBe('ka');
|
||||
|
||||
expect(callCount).toBe(2);
|
||||
expect(i18n.getResource('ka', 'translation', 'com_ui_examples')).toBe('ქართული მაგალითები');
|
||||
|
||||
restoreLoader();
|
||||
__resetLocaleForTests('ka');
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should only apply the newest rapid language switch', async () => {
|
||||
__resetLocaleForTests('sv');
|
||||
__resetLocaleForTests('ka');
|
||||
__resetLocaleForTests('sl');
|
||||
|
||||
const svLocale = deferred<{ default: TranslationResource }>();
|
||||
const kaLocale = deferred<{ default: TranslationResource }>();
|
||||
const slLocale = deferred<{ default: TranslationResource }>();
|
||||
const restoreSv = __setLocaleLoaderForTests('sv', () => svLocale.promise);
|
||||
const restoreKa = __setLocaleLoaderForTests('ka', () => kaLocale.promise);
|
||||
const restoreSl = __setLocaleLoaderForTests('sl', () => slLocale.promise);
|
||||
|
||||
const firstSwitch = changeLanguageSafely('sv-SE');
|
||||
const secondSwitch = changeLanguageSafely('ka-GE');
|
||||
const latestSwitch = changeLanguageSafely('sl');
|
||||
|
||||
svLocale.resolve({ default: { com_ui_examples: 'svenska exempel' } });
|
||||
await firstSwitch;
|
||||
expect(i18n.language).not.toBe('sv');
|
||||
|
||||
kaLocale.resolve({ default: { com_ui_examples: 'ქართული მაგალითები' } });
|
||||
await secondSwitch;
|
||||
expect(i18n.language).not.toBe('ka');
|
||||
|
||||
slLocale.resolve({ default: { com_ui_examples: 'slovenski primeri' } });
|
||||
await expect(latestSwitch).resolves.toBe('sl');
|
||||
expect(i18n.language).toBe('sl');
|
||||
expect(document.documentElement.lang).toBe('sl');
|
||||
|
||||
restoreSv();
|
||||
restoreKa();
|
||||
restoreSl();
|
||||
__resetLocaleForTests('sv');
|
||||
__resetLocaleForTests('ka');
|
||||
__resetLocaleForTests('sl');
|
||||
});
|
||||
|
||||
it('should restore the newest language if an older change finishes late', async () => {
|
||||
__resetLocaleForTests('sv');
|
||||
__resetLocaleForTests('sl');
|
||||
|
||||
const svLocale = deferred<{ default: TranslationResource }>();
|
||||
const slLocale = deferred<{ default: TranslationResource }>();
|
||||
const restoreSv = __setLocaleLoaderForTests('sv', () => svLocale.promise);
|
||||
const restoreSl = __setLocaleLoaderForTests('sl', () => slLocale.promise);
|
||||
|
||||
const firstSwitch = changeLanguageSafely('sv-SE');
|
||||
const latestSwitch = changeLanguageSafely('sl');
|
||||
|
||||
slLocale.resolve({ default: { com_ui_examples: 'slovenski primeri' } });
|
||||
await expect(latestSwitch).resolves.toBe('sl');
|
||||
expect(i18n.language).toBe('sl');
|
||||
|
||||
svLocale.resolve({ default: { com_ui_examples: 'svenska exempel' } });
|
||||
await firstSwitch;
|
||||
expect(i18n.language).toBe('sl');
|
||||
expect(document.documentElement.lang).toBe('sl');
|
||||
|
||||
restoreSv();
|
||||
restoreSl();
|
||||
__resetLocaleForTests('sv');
|
||||
__resetLocaleForTests('sl');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,112 +1,337 @@
|
|||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
// Import your JSON translations
|
||||
import translationEn from './en/translation.json';
|
||||
import translationAr from './ar/translation.json';
|
||||
import translationCa from './ca/translation.json';
|
||||
import translationCs from './cs/translation.json';
|
||||
import translationDa from './da/translation.json';
|
||||
import translationDe from './de/translation.json';
|
||||
import translationEs from './es/translation.json';
|
||||
import translationEt from './et/translation.json';
|
||||
import translationFa from './fa/translation.json';
|
||||
import translationFr from './fr/translation.json';
|
||||
import translationIt from './it/translation.json';
|
||||
import translationPl from './pl/translation.json';
|
||||
import translationPt_BR from './pt-BR/translation.json';
|
||||
import translationPt_PT from './pt-PT/translation.json';
|
||||
import translationRu from './ru/translation.json';
|
||||
import translationJa from './ja/translation.json';
|
||||
import translationKa from './ka/translation.json';
|
||||
import translationSv from './sv/translation.json';
|
||||
import translationKo from './ko/translation.json';
|
||||
import translationLt from './lt/translation.json';
|
||||
import translationLv from './lv/translation.json';
|
||||
import translationTh from './th/translation.json';
|
||||
import translationTr from './tr/translation.json';
|
||||
import translationUg from './ug/translation.json';
|
||||
import translationVi from './vi/translation.json';
|
||||
import translationNl from './nl/translation.json';
|
||||
import translationNn from './nn/translation.json';
|
||||
import translationId from './id/translation.json';
|
||||
import translationIs from './is/translation.json';
|
||||
import translationHe from './he/translation.json';
|
||||
import translationHu from './hu/translation.json';
|
||||
import translationHy from './hy/translation.json';
|
||||
import translationFi from './fi/translation.json';
|
||||
import translationZh_Hans from './zh-Hans/translation.json';
|
||||
import translationZh_Hant from './zh-Hant/translation.json';
|
||||
import translationSk from './sk/translation.json';
|
||||
import translationBo from './bo/translation.json';
|
||||
import translationUk from './uk/translation.json';
|
||||
import translationBs from './bs/translation.json';
|
||||
import translationNb from './nb/translation.json';
|
||||
import translationSl from './sl/translation.json';
|
||||
|
||||
export const defaultNS = 'translation';
|
||||
|
||||
export const supportedLocales = [
|
||||
'ar',
|
||||
'bo',
|
||||
'bs',
|
||||
'ca',
|
||||
'cs',
|
||||
'da',
|
||||
'de',
|
||||
'en',
|
||||
'es',
|
||||
'et',
|
||||
'fa',
|
||||
'fi',
|
||||
'fr',
|
||||
'he',
|
||||
'hu',
|
||||
'hy',
|
||||
'id',
|
||||
'is',
|
||||
'it',
|
||||
'ja',
|
||||
'ka',
|
||||
'ko',
|
||||
'lt',
|
||||
'lv',
|
||||
'nb',
|
||||
'nl',
|
||||
'nn',
|
||||
'pl',
|
||||
'pt-BR',
|
||||
'pt-PT',
|
||||
'ru',
|
||||
'sk',
|
||||
'sl',
|
||||
'sv',
|
||||
'th',
|
||||
'tr',
|
||||
'ug',
|
||||
'uk',
|
||||
'vi',
|
||||
'zh-Hans',
|
||||
'zh-Hant',
|
||||
] as const;
|
||||
|
||||
export type SupportedLocale = (typeof supportedLocales)[number];
|
||||
export type TranslationResource = Record<string, string>;
|
||||
|
||||
export const resources = {
|
||||
en: { translation: translationEn },
|
||||
ar: { translation: translationAr },
|
||||
bs: { translation: translationBs },
|
||||
ca: { translation: translationCa },
|
||||
cs: { translation: translationCs },
|
||||
'zh-Hans': { translation: translationZh_Hans },
|
||||
'zh-Hant': { translation: translationZh_Hant },
|
||||
da: { translation: translationDa },
|
||||
de: { translation: translationDe },
|
||||
es: { translation: translationEs },
|
||||
et: { translation: translationEt },
|
||||
fa: { translation: translationFa },
|
||||
fr: { translation: translationFr },
|
||||
it: { translation: translationIt },
|
||||
nb: { translation: translationNb },
|
||||
pl: { translation: translationPl },
|
||||
'pt-BR': { translation: translationPt_BR },
|
||||
'pt-PT': { translation: translationPt_PT },
|
||||
ru: { translation: translationRu },
|
||||
ja: { translation: translationJa },
|
||||
ka: { translation: translationKa },
|
||||
sv: { translation: translationSv },
|
||||
ko: { translation: translationKo },
|
||||
lt: { translation: translationLt },
|
||||
lv: { translation: translationLv },
|
||||
th: { translation: translationTh },
|
||||
tr: { translation: translationTr },
|
||||
ug: { translation: translationUg },
|
||||
vi: { translation: translationVi },
|
||||
nl: { translation: translationNl },
|
||||
nn: { translation: translationNn },
|
||||
id: { translation: translationId },
|
||||
is: { translation: translationIs },
|
||||
he: { translation: translationHe },
|
||||
hu: { translation: translationHu },
|
||||
hy: { translation: translationHy },
|
||||
fi: { translation: translationFi },
|
||||
sk: { translation: translationSk },
|
||||
bo: { translation: translationBo },
|
||||
sl: { translation: translationSl },
|
||||
uk: { translation: translationUk },
|
||||
} as const;
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: {
|
||||
'zh-TW': ['zh-Hant', 'en'],
|
||||
'zh-HK': ['zh-Hant', 'en'],
|
||||
zh: ['zh-Hans', 'en'],
|
||||
default: ['en'],
|
||||
},
|
||||
fallbackNS: 'translation',
|
||||
ns: ['translation'],
|
||||
debug: false,
|
||||
defaultNS,
|
||||
resources,
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
const localeLoaders: Record<
|
||||
Exclude<SupportedLocale, 'en'>,
|
||||
() => Promise<{ default: TranslationResource }>
|
||||
> = {
|
||||
ar: () => import('./ar/translation.json'),
|
||||
bo: () => import('./bo/translation.json'),
|
||||
bs: () => import('./bs/translation.json'),
|
||||
ca: () => import('./ca/translation.json'),
|
||||
cs: () => import('./cs/translation.json'),
|
||||
da: () => import('./da/translation.json'),
|
||||
de: () => import('./de/translation.json'),
|
||||
es: () => import('./es/translation.json'),
|
||||
et: () => import('./et/translation.json'),
|
||||
fa: () => import('./fa/translation.json'),
|
||||
fi: () => import('./fi/translation.json'),
|
||||
fr: () => import('./fr/translation.json'),
|
||||
he: () => import('./he/translation.json'),
|
||||
hu: () => import('./hu/translation.json'),
|
||||
hy: () => import('./hy/translation.json'),
|
||||
id: () => import('./id/translation.json'),
|
||||
is: () => import('./is/translation.json'),
|
||||
it: () => import('./it/translation.json'),
|
||||
ja: () => import('./ja/translation.json'),
|
||||
ka: () => import('./ka/translation.json'),
|
||||
ko: () => import('./ko/translation.json'),
|
||||
lt: () => import('./lt/translation.json'),
|
||||
lv: () => import('./lv/translation.json'),
|
||||
nb: () => import('./nb/translation.json'),
|
||||
nl: () => import('./nl/translation.json'),
|
||||
nn: () => import('./nn/translation.json'),
|
||||
pl: () => import('./pl/translation.json'),
|
||||
'pt-BR': () => import('./pt-BR/translation.json'),
|
||||
'pt-PT': () => import('./pt-PT/translation.json'),
|
||||
ru: () => import('./ru/translation.json'),
|
||||
sk: () => import('./sk/translation.json'),
|
||||
sl: () => import('./sl/translation.json'),
|
||||
sv: () => import('./sv/translation.json'),
|
||||
th: () => import('./th/translation.json'),
|
||||
tr: () => import('./tr/translation.json'),
|
||||
ug: () => import('./ug/translation.json'),
|
||||
uk: () => import('./uk/translation.json'),
|
||||
vi: () => import('./vi/translation.json'),
|
||||
'zh-Hans': () => import('./zh-Hans/translation.json'),
|
||||
'zh-Hant': () => import('./zh-Hant/translation.json'),
|
||||
};
|
||||
|
||||
const localeByLowercase = supportedLocales.reduce<Record<string, SupportedLocale>>(
|
||||
(acc, locale) => {
|
||||
acc[locale.toLowerCase()] = locale;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const localeAliases: Record<string, SupportedLocale> = {
|
||||
'ar-eg': 'ar',
|
||||
'ca-es': 'ca',
|
||||
'cs-cz': 'cs',
|
||||
'da-dk': 'da',
|
||||
'de-de': 'de',
|
||||
'en-us': 'en',
|
||||
'es-es': 'es',
|
||||
'et-ee': 'et',
|
||||
'fa-ir': 'fa',
|
||||
'fi-fi': 'fi',
|
||||
'fr-fr': 'fr',
|
||||
'he-he': 'he',
|
||||
'he-il': 'he',
|
||||
'hu-hu': 'hu',
|
||||
'hy-am': 'hy',
|
||||
'id-id': 'id',
|
||||
'it-it': 'it',
|
||||
'ja-jp': 'ja',
|
||||
'ka-ge': 'ka',
|
||||
'ko-kr': 'ko',
|
||||
'lt-lt': 'lt',
|
||||
'lv-lv': 'lv',
|
||||
'nl-nl': 'nl',
|
||||
'pl-pl': 'pl',
|
||||
pt: 'pt-PT',
|
||||
'ru-ru': 'ru',
|
||||
'sv-se': 'sv',
|
||||
'th-th': 'th',
|
||||
'tr-tr': 'tr',
|
||||
'uk-ua': 'uk',
|
||||
'vi-vn': 'vi',
|
||||
zh: 'zh-Hans',
|
||||
'zh-cn': 'zh-Hans',
|
||||
'zh-sg': 'zh-Hans',
|
||||
'zh-tw': 'zh-Hant',
|
||||
'zh-hk': 'zh-Hant',
|
||||
'zh-mo': 'zh-Hant',
|
||||
};
|
||||
|
||||
const loadedLocales = new Set<SupportedLocale>(['en']);
|
||||
const loadingLocales: Partial<Record<SupportedLocale, Promise<SupportedLocale>>> = {};
|
||||
let languageRequestId = 0;
|
||||
let latestRequestedLocale: SupportedLocale = 'en';
|
||||
|
||||
function readCookie(name: string) {
|
||||
if (typeof document === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prefix = `${name}=`;
|
||||
return document.cookie
|
||||
.split(';')
|
||||
.map((cookie) => cookie.trim())
|
||||
.find((cookie) => cookie.startsWith(prefix))
|
||||
?.slice(prefix.length);
|
||||
}
|
||||
|
||||
function readStoredLanguage() {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem('lang');
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return typeof parsed === 'string' ? parsed : raw;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function getNavigatorLanguage() {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
return navigator.language || navigator.languages?.[0] || 'en';
|
||||
}
|
||||
|
||||
export function normalizeLocale(locale?: string | null): SupportedLocale {
|
||||
const requested = locale === 'auto' ? getNavigatorLanguage() : locale;
|
||||
if (!requested) {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
const normalized = requested.replace(/_/g, '-').toLowerCase();
|
||||
const exact = localeByLowercase[normalized];
|
||||
if (exact) {
|
||||
return exact;
|
||||
}
|
||||
|
||||
const alias = localeAliases[normalized];
|
||||
if (alias) {
|
||||
return alias;
|
||||
}
|
||||
|
||||
const base = normalized.split('-')[0];
|
||||
return localeByLowercase[base] ?? localeAliases[base] ?? 'en';
|
||||
}
|
||||
|
||||
export function detectInitialLanguage() {
|
||||
const cookieLang = readCookie('lang');
|
||||
const storedLang = readStoredLanguage();
|
||||
return normalizeLocale(cookieLang || storedLang || getNavigatorLanguage());
|
||||
}
|
||||
|
||||
export async function ensureLocale(locale?: string | null): Promise<SupportedLocale> {
|
||||
const normalized = normalizeLocale(locale);
|
||||
|
||||
if (normalized === 'en') {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (loadedLocales.has(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (i18n.hasResourceBundle(normalized, defaultNS)) {
|
||||
loadedLocales.add(normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (!loadingLocales[normalized]) {
|
||||
const loader = localeLoaders[normalized];
|
||||
loadingLocales[normalized] = loader()
|
||||
.then((module) => {
|
||||
i18n.addResourceBundle(normalized, defaultNS, module.default, true, true);
|
||||
loadedLocales.add(normalized);
|
||||
return normalized;
|
||||
})
|
||||
.catch((error): SupportedLocale => {
|
||||
console.error(`[i18n] Failed to load locale "${normalized}"`, error);
|
||||
return 'en';
|
||||
})
|
||||
.finally(() => {
|
||||
delete loadingLocales[normalized];
|
||||
});
|
||||
}
|
||||
|
||||
return loadingLocales[normalized] ?? Promise.resolve('en');
|
||||
}
|
||||
|
||||
export function __setLocaleLoaderForTests(
|
||||
locale: Exclude<SupportedLocale, 'en'>,
|
||||
loader: () => Promise<{ default: TranslationResource }>,
|
||||
) {
|
||||
const previousLoader = localeLoaders[locale];
|
||||
localeLoaders[locale] = loader;
|
||||
return () => {
|
||||
localeLoaders[locale] = previousLoader;
|
||||
};
|
||||
}
|
||||
|
||||
export function __resetLocaleForTests(locale: Exclude<SupportedLocale, 'en'>) {
|
||||
delete loadingLocales[locale];
|
||||
loadedLocales.delete(locale);
|
||||
if (i18n.hasResourceBundle(locale, defaultNS)) {
|
||||
i18n.removeResourceBundle(locale, defaultNS);
|
||||
}
|
||||
}
|
||||
|
||||
export function syncDocumentLanguage(locale: SupportedLocale) {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.lang = locale;
|
||||
document.documentElement.dir = i18n.dir(locale);
|
||||
}
|
||||
|
||||
export const i18nInitPromise = i18n.use(initReactI18next).init({
|
||||
lng: 'en',
|
||||
fallbackLng: {
|
||||
'zh-TW': ['zh-Hant', 'en'],
|
||||
'zh-HK': ['zh-Hant', 'en'],
|
||||
zh: ['zh-Hans', 'en'],
|
||||
default: ['en'],
|
||||
},
|
||||
fallbackNS: defaultNS,
|
||||
ns: [defaultNS],
|
||||
debug: false,
|
||||
defaultNS,
|
||||
resources,
|
||||
supportedLngs: [...supportedLocales],
|
||||
partialBundledLanguages: true,
|
||||
load: 'currentOnly',
|
||||
react: { useSuspense: false },
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
export async function changeLanguageSafely(locale?: string | null) {
|
||||
const requestId = ++languageRequestId;
|
||||
const requestedLocale = normalizeLocale(locale);
|
||||
latestRequestedLocale = requestedLocale;
|
||||
await i18nInitPromise;
|
||||
|
||||
const loadedLocale = await ensureLocale(requestedLocale);
|
||||
if (requestId !== languageRequestId) {
|
||||
return i18n.language;
|
||||
}
|
||||
|
||||
await i18n.changeLanguage(loadedLocale);
|
||||
if (requestId !== languageRequestId) {
|
||||
const latestLocale = await ensureLocale(latestRequestedLocale);
|
||||
await i18n.changeLanguage(latestLocale);
|
||||
syncDocumentLanguage(latestLocale);
|
||||
return i18n.language;
|
||||
}
|
||||
|
||||
syncDocumentLanguage(loadedLocale);
|
||||
return loadedLocale;
|
||||
}
|
||||
|
||||
export async function initializeI18n() {
|
||||
const initialLanguage = detectInitialLanguage();
|
||||
await changeLanguageSafely(initialLanguage);
|
||||
return initialLanguage;
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import './polyfills/regeneratorRuntime';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './locales/i18n';
|
||||
import { initializeI18n } from './locales/i18n';
|
||||
import App from './App';
|
||||
import '@librechat/client/style.css';
|
||||
import './style.css';
|
||||
|
|
@ -12,8 +12,21 @@ import 'katex/dist/contrib/copy-tex.js';
|
|||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
|
||||
root.render(
|
||||
<ApiErrorBoundaryProvider>
|
||||
<App />
|
||||
</ApiErrorBoundaryProvider>,
|
||||
);
|
||||
async function bootstrap() {
|
||||
await initializeI18n();
|
||||
|
||||
root.render(
|
||||
<ApiErrorBoundaryProvider>
|
||||
<App />
|
||||
</ApiErrorBoundaryProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
console.error('[i18n] Failed to initialize before render', error);
|
||||
root.render(
|
||||
<ApiErrorBoundaryProvider>
|
||||
<App />
|
||||
</ApiErrorBoundaryProvider>,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,36 @@
|
|||
import { atom } from 'recoil';
|
||||
import Cookies from 'js-cookie';
|
||||
import { atomWithLocalStorage } from './utils';
|
||||
|
||||
const readStoredLang = () => {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const storedLang = localStorage.getItem('lang');
|
||||
if (!storedLang) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedLang = JSON.parse(storedLang);
|
||||
return typeof parsedLang === 'string' ? parsedLang : storedLang;
|
||||
} catch {
|
||||
return storedLang;
|
||||
}
|
||||
};
|
||||
|
||||
const defaultLang = () => {
|
||||
const userLang = navigator.language || navigator.languages[0];
|
||||
return Cookies.get('lang') || localStorage.getItem('lang') || userLang;
|
||||
const userLang =
|
||||
(typeof navigator !== 'undefined' ? navigator.language || navigator.languages?.[0] : null) ??
|
||||
'en';
|
||||
return Cookies.get('lang') || readStoredLang() || userLang;
|
||||
};
|
||||
|
||||
const lang = atomWithLocalStorage('lang', defaultLang());
|
||||
const languageLoading = atom<boolean>({
|
||||
key: 'languageLoading',
|
||||
default: false,
|
||||
});
|
||||
|
||||
export default { lang };
|
||||
export default { lang, languageLoading };
|
||||
|
|
|
|||
|
|
@ -83,10 +83,32 @@ export default defineConfig(({ command }) => ({
|
|||
'assets/maskable-icon.png',
|
||||
'manifest.webmanifest',
|
||||
],
|
||||
globIgnores: ['images/**/*', '**/*.map', 'index.html', 'assets/rum.*.js'],
|
||||
globIgnores: [
|
||||
'images/**/*',
|
||||
'**/*.map',
|
||||
'index.html',
|
||||
'assets/rum.*.js',
|
||||
'assets/locale-*.js',
|
||||
],
|
||||
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
|
||||
/** LibreChat mutates index.html per request for subpath and language support. */
|
||||
navigateFallback: null,
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ url }) => /\/assets\/locale-[^/]+\.js$/.test(url.pathname),
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'locale-chunks',
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200],
|
||||
},
|
||||
expiration: {
|
||||
maxEntries: 80,
|
||||
maxAgeSeconds: 30 * 24 * 60 * 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
includeAssets: [],
|
||||
manifest: {
|
||||
|
|
@ -306,9 +328,12 @@ export default defineConfig(({ command }) => ({
|
|||
if (normalizedId.includes('/src/polyfills/')) {
|
||||
return 'polyfills';
|
||||
}
|
||||
// Create a separate chunk for all locale files under src/locales.
|
||||
if (normalizedId.includes('/src/locales/')) {
|
||||
return 'locales';
|
||||
// Keep lazy-loaded locale files in one chunk per locale.
|
||||
const localeMatch = normalizedId.match(
|
||||
/\/src\/locales\/([^/]+)\/translation\.json$/,
|
||||
);
|
||||
if (localeMatch) {
|
||||
return localeMatch[1] === 'en' ? null : `locale-${localeMatch[1]}`;
|
||||
}
|
||||
// Let Rolldown decide automatically for any other files.
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { useCallback } from 'react';
|
||||
import { TOptions } from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { resources } from '~/locales/i18n';
|
||||
|
||||
export type TranslationKeys = keyof typeof resources.en.translation;
|
||||
export type TranslationKeys = string;
|
||||
|
||||
/** Language lifecycle is managed by the host app — do not add i18n.changeLanguage() calls here. */
|
||||
export default function useLocalize(): (phraseKey: TranslationKeys, options?: TOptions) => string {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue