From 648f71d908cf36aa98d1f02f46d256ef2bfb5b4a Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Wed, 23 Jul 2025 11:58:33 +0800 Subject: [PATCH] use navigator preferred languages to select locale --- next-ui/package-lock.json | 1 + next-ui/package.json | 1 + next-ui/src/plugins/vue-intl.ts | 4 +- next-ui/src/plugins/vuetify.ts | 4 +- next-ui/src/utils/i18n/locale-helper.test.ts | 100 +++++++++++-------- next-ui/src/utils/i18n/locale-helper.ts | 23 +++-- 6 files changed, 83 insertions(+), 50 deletions(-) diff --git a/next-ui/package-lock.json b/next-ui/package-lock.json index d4604f11..472b60dc 100644 --- a/next-ui/package-lock.json +++ b/next-ui/package-lock.json @@ -8,6 +8,7 @@ "name": "next-ui", "version": "0.0.0", "dependencies": { + "@formatjs/intl-localematcher": "^0.6.1", "@pinia/colada": "^0.17.1", "@pinia/colada-plugin-auto-refetch": "^0.2.0", "@vueuse/core": "^13.5.0", diff --git a/next-ui/package.json b/next-ui/package.json index d2efb524..baa04c24 100644 --- a/next-ui/package.json +++ b/next-ui/package.json @@ -28,6 +28,7 @@ "chromatic": "npm run chromatic" }, "dependencies": { + "@formatjs/intl-localematcher": "^0.6.1", "@pinia/colada": "^0.17.1", "@pinia/colada-plugin-auto-refetch": "^0.2.0", "@vueuse/core": "^13.5.0", diff --git a/next-ui/src/plugins/vue-intl.ts b/next-ui/src/plugins/vue-intl.ts index b228e5ce..a09ca4d0 100644 --- a/next-ui/src/plugins/vue-intl.ts +++ b/next-ui/src/plugins/vue-intl.ts @@ -1,10 +1,10 @@ import { createIntl } from 'vue-intl' -import { currentLocale, defaultLocale, loadLocale } from '@/utils/i18n/locale-helper' +import { currentLocale, fallbackLocale, loadLocale } from '@/utils/i18n/locale-helper' const messages = loadLocale(currentLocale) export const vueIntl = createIntl({ locale: currentLocale, - defaultLocale: defaultLocale, + defaultLocale: fallbackLocale, messages, }) diff --git a/next-ui/src/plugins/vuetify.ts b/next-ui/src/plugins/vuetify.ts index 8542f758..f6cd908a 100644 --- a/next-ui/src/plugins/vuetify.ts +++ b/next-ui/src/plugins/vuetify.ts @@ -16,7 +16,7 @@ import { md3 } from 'vuetify/blueprints' import { VIconBtn } from 'vuetify/labs/components' import { createRulesPlugin } from 'vuetify/labs/rules' -import { availableLocales, currentLocale, defaultLocale } from '@/utils/i18n/locale-helper' +import { availableLocales, currentLocale, fallbackLocale } from '@/utils/i18n/locale-helper' // load vuetify locales only for the available locales in i18n async function loadVuetifyLocale(locale: string) { @@ -34,7 +34,7 @@ void (async () => { export const vuetify = createVuetify({ locale: { locale: currentLocale, - fallback: defaultLocale, + fallback: fallbackLocale, messages, }, icons: { diff --git a/next-ui/src/utils/i18n/locale-helper.test.ts b/next-ui/src/utils/i18n/locale-helper.test.ts index 73c7e217..9165452a 100644 --- a/next-ui/src/utils/i18n/locale-helper.test.ts +++ b/next-ui/src/utils/i18n/locale-helper.test.ts @@ -1,42 +1,64 @@ -import { beforeEach, expect, test, vi } from 'vitest' -import { loadLocale, defaultLocale, setLocale, getLocale, availableLocales } from './locale-helper' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { loadLocale, fallbackLocale, setLocale, getLocale, availableLocales } from './locale-helper' -beforeEach(() => { - // mock the available locales, as locales are checked against what's available - vi.mock('@/i18n?dir2json&ext=.json&1', () => { - return { - default: { - en: { - sample: 'sample', - }, - fr: { - sample: 'échantillon', - }, - } as Record>, - } +describe('locale', () => { + beforeEach(() => { + // mock the available locales, as locales are checked against what's available + vi.mock('@/i18n?dir2json&ext=.json&1', () => { + return { + default: { + en: { + sample: 'sample', + }, + fr: { + sample: 'échantillon', + }, + } as Record>, + } + }) + }) + + afterEach(() => { + localStorage.clear() + }) + + test('given available locales when getting available locales then they are returned', () => { + expect(Object.keys(availableLocales)).toStrictEqual(['en', 'fr']) + }) + + test('when trying to load unknown locale then fallback locale is loaded', () => { + const localeFallback = loadLocale(fallbackLocale) + const localeUnknown = loadLocale('unknown') + + expect(localeUnknown).toBe(localeFallback) + }) + + test('when setting locale then it is persisted', () => { + const spy = vi.fn(() => {}) + vi.spyOn(window, 'location', 'get').mockReturnValue({ + reload: spy, + } as unknown as Location) + + setLocale('fr') + const newLocale = getLocale() + + expect(newLocale).toBe('fr') + expect(spy).toHaveBeenCalledOnce() + }) + + test('given browser preferred languages in available locales when getting locale then preferred language is used', () => { + vi.spyOn(navigator, 'languages', 'get').mockReturnValue(['fr-XX', 'zh']) + + const locale = getLocale() + + expect(locale).toBe('fr') + }) + + test('given browser preferred languages not in available locales when getting locale then fallback locale is used', () => { + vi.spyOn(navigator, 'languages', 'get').mockReturnValue(['ja-JP', 'zh-CN']) + + const locale = getLocale() + + expect(locale).toBe(fallbackLocale) }) }) - -test('given available locales when getting available locales then they are returned', () => { - expect(Object.keys(availableLocales)).toStrictEqual(['en', 'fr']) -}) - -test('when trying to load unknown locale then default locale is loaded', () => { - const localeDefault = loadLocale(defaultLocale) - const localeUnknown = loadLocale('unknown') - - expect(localeUnknown).toBe(localeDefault) -}) - -test('when setting locale then it is persisted', () => { - const spy = vi.fn(() => {}) - vi.spyOn(window, 'location', 'get').mockReturnValue({ - reload: spy, - } as unknown as Location) - - setLocale('fr') - const newLocale = getLocale() - - expect(newLocale).toBe('fr') - expect(spy).toHaveBeenCalledOnce() -}) diff --git a/next-ui/src/utils/i18n/locale-helper.ts b/next-ui/src/utils/i18n/locale-helper.ts index 2b486b52..52b1095d 100644 --- a/next-ui/src/utils/i18n/locale-helper.ts +++ b/next-ui/src/utils/i18n/locale-helper.ts @@ -1,7 +1,8 @@ import { defineMessage } from 'vue-intl' import localeMessages from '@/i18n?dir2json&ext=.json&1' +import { match } from '@formatjs/intl-localematcher' -export const defaultLocale = 'en' +export const fallbackLocale = 'en' const USER_LOCALE_KEY = 'komga.userLocale' @@ -14,11 +15,11 @@ const localeName = defineMessage({ /** * Loads messages from a translation file by its locale code. - * If the translation file does not exist, loads the `defaultLocale` instead. + * If the translation file does not exist, loads the `fallbackLocale` instead. * @param locale the locale code, e.g. 'fr' */ export function loadLocale(locale: string): Record { - const localeToLoad = locale in availableLocales ? locale : defaultLocale + const localeToLoad = locale in availableLocales ? locale : fallbackLocale return (localeMessages as unknown as Record>)[localeToLoad]! } @@ -41,12 +42,20 @@ function loadAvailableLocales(): Record { export const availableLocales = loadAvailableLocales() /** - * Gets the saved locale from localStorage. - * If the locale is not valid, defaults to 'en'. + * Gets the saved locale from localStorage if defined and valid. + * Else tries to get the best matching language from the brower's preferred languages. + * If the locale is not valid, defaults to 'fallbackLocale'. */ export function getLocale(): string { - const storageLocale = localStorage.getItem(USER_LOCALE_KEY) ?? defaultLocale - return storageLocale in availableLocales ? storageLocale : defaultLocale + const storageLocale = localStorage.getItem(USER_LOCALE_KEY) + console.log('locale from storage:', storageLocale) + if (storageLocale && storageLocale in availableLocales) return storageLocale + + // get the browser's preferred languages and see if we can match it to an available locale + console.log('preferred languages:', navigator.languages) + const s = match(navigator.languages, Object.keys(availableLocales), fallbackLocale) + console.log('match:', s) + return s } export const currentLocale = getLocale()