diff --git a/AGENTS.md b/AGENTS.md index 583ed8a78d..c36118d1b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -261,6 +261,14 @@ src/ - `I18nLocales` — locale YAML files - `CameraComponents` — camera service Vue components - Views also dynamically imported in `src/router/index.ts` via `() => import('@/views/X.vue')` +- `src/plugins/vuetify.ts` holds a private `locales` map of `() => import('vuetify/lib/locale/*')` + thunks, keyed by **Fluidd** locale code, so the map doubles as the code translation + (`zh-CN` → `zh-Hans`, `zh-HK` → `zh-Hant`, `pt_BR` → `pt`); a missing key (`ta`) leaves + `$vuetify.lang` on `en`. `config/onLocaleChange` drives it via `loadVuetifyLocaleAsync`, which + `Vue.set`s the locale into `lang.locales` **before** assigning `lang.current` — the `current` + write is what re-renders. These `import()`s do not actually code-split: `vuetify/lib/presets/default` + statically imports the `lib/locale` barrel, so every referenced locale lands in the eager + `vuetify` chunk regardless of `codeSplitting` groups ## Testing Conventions diff --git a/docs/docs/development.md b/docs/docs/development.md index 8c86ee5e0b..30fdfe64e0 100644 --- a/docs/docs/development.md +++ b/docs/docs/development.md @@ -247,6 +247,23 @@ Fluidd uses [vue-i18n](https://kazupon.github.io/vue-i18n/) for its localization Locales can be found in the `src/locales` folder and are in YAML format. +### Vuetify component translations + +Vuetify keeps its own string table, separate from `src/locales`, for strings its +components render without Fluidd passing them — the `v-data-table` footer, +pagination and sort aria-labels, and the `v-file-input` counter. These +translations ship with Vuetify and are **not** managed through Weblate, so +editing `src/locales` will not change them. + +`src/plugins/vuetify.ts` maps each Fluidd locale code onto the matching Vuetify +one and loads it when the language changes. Most codes match one-to-one; the +exceptions are `zh-CN`, `zh-HK` and `pt_BR`, which map onto Vuetify's `zh-Hans`, +`zh-Hant` and `pt`. A code left out of that map falls back to English, which is +what happens for Tamil — Vuetify has no `ta` translation. + +Adding a language therefore needs an entry in that map alongside the one in +`SupportedLocales` (`src/globals.ts`), unless Vuetify has no translation for it. + ### How to contribute Translations are hosted on Weblate. If you want to help translating our project, please click the widget below: diff --git a/docs/docs/features/localization.md b/docs/docs/features/localization.md index e8f9de1ace..8b0159f83c 100644 --- a/docs/docs/features/localization.md +++ b/docs/docs/features/localization.md @@ -22,8 +22,7 @@ Translations are managed through [Weblate](https://hosted.weblate.org/engage/fluidd/). Do not edit non-English locale files directly — use Weblate instead. The [Weblate project page](https://hosted.weblate.org/engage/fluidd/) shows the -current completion percentage for each language, and new languages can be added -there without needing a code change. +current completion percentage for each language. See the [developer localization](/development#localization) docs for technical details on how translations work. diff --git a/src/components/settings/GeneralSettings.vue b/src/components/settings/GeneralSettings.vue index 5ea4d22230..0bfb7dedb4 100644 --- a/src/components/settings/GeneralSettings.vue +++ b/src/components/settings/GeneralSettings.vue @@ -34,6 +34,7 @@ hide-details="auto" :items="supportedLocales" :value="locale" + :loading="hasWait($waits.onLoadLanguage)" item-text="name" item-value="code" @change="setLocale" diff --git a/src/plugins/__tests__/i18n.spec.ts b/src/plugins/__tests__/i18n.spec.ts new file mode 100644 index 0000000000..f03463e902 --- /dev/null +++ b/src/plugins/__tests__/i18n.spec.ts @@ -0,0 +1,67 @@ +import { getStartingLocale } from '../i18n' + +describe('getStartingLocale', () => { + const originalLanguages = Object.getOwnPropertyDescriptor(navigator.constructor.prototype, 'languages') + + const setNavigatorLanguages = (languages: string[]) => { + Object.defineProperty(navigator, 'languages', { + configurable: true, + value: languages + }) + } + + afterEach(() => { + if (originalLanguages) { + Object.defineProperty(navigator.constructor.prototype, 'languages', originalLanguages) + } + + Reflect.deleteProperty(navigator, 'languages') + }) + + it.each([ + [['zh-CN', 'en-US'], 'zh-CN'], + [['zh-HK'], 'zh-HK'], + [['de'], 'de'], + [['fr-CA'], 'fr'] + ])('matches the full locale code before the language (%j)', (languages, expected) => { + setNavigatorLanguages(languages) + + expect(getStartingLocale()).toBe(expected) + }) + + it('falls back to the language when the full code is unsupported', () => { + setNavigatorLanguages(['pt-BR']) + + expect(getStartingLocale()).toBe('pt') + }) + + it('normalizes the casing of the matched code', () => { + setNavigatorLanguages(['zh-hk']) + + expect(getStartingLocale()).toBe('zh-HK') + }) + + it.each([ + [['pt_BR'], 'pt'], + [['zh_CN'], 'zh-CN'] + ])('takes the underscore form of a locale code (%j)', (languages, expected) => { + setNavigatorLanguages(languages) + + expect(getStartingLocale()).toBe(expected) + }) + + it('walks the preferences in order until one is supported', () => { + setNavigatorLanguages(['nb-NO', 'de-DE']) + + expect(getStartingLocale()).toBe('de') + }) + + it.each([ + [['xx-YY']], + [[]] + ])('falls back to english when nothing is supported (%j)', (languages) => { + setNavigatorLanguages(languages) + + expect(getStartingLocale()).toBe('en') + }) +}) diff --git a/src/plugins/__tests__/vuetify.spec.ts b/src/plugins/__tests__/vuetify.spec.ts new file mode 100644 index 0000000000..c84f0ea67f --- /dev/null +++ b/src/plugins/__tests__/vuetify.spec.ts @@ -0,0 +1,41 @@ +import vuetify, { loadVuetifyLocaleAsync } from '../vuetify' + +describe('loadVuetifyLocaleAsync', () => { + const { lang } = vuetify.framework + + beforeEach(() => { + lang.current = 'en' + }) + + it.each([ + ['de', 'Schließen'], + ['pt', 'Fechar'], + ['pt_BR', 'Fechar'], + ['zh-CN', '关闭'], + ['zh-HK', '關閉'] + ])('loads and applies the vuetify locale (%s)', async (locale, close) => { + await loadVuetifyLocaleAsync(locale) + + expect(lang.current).toBe(locale) + expect(lang.t('$vuetify.close')).toBe(close) + }) + + it('falls back to english when the locale has no vuetify translation', async () => { + await loadVuetifyLocaleAsync('pt') + await loadVuetifyLocaleAsync('ta') + + expect(lang.current).toBe('en') + expect(lang.t('$vuetify.close')).toBe('Close') + }) + + it('does not reload an already loaded locale', async () => { + await loadVuetifyLocaleAsync('fr') + + const messages = lang.locales.fr + + await loadVuetifyLocaleAsync('en') + await loadVuetifyLocaleAsync('fr') + + expect(lang.locales.fr).toBe(messages) + }) +}) diff --git a/src/plugins/i18n.ts b/src/plugins/i18n.ts index 18dacb7a5f..795c193de5 100644 --- a/src/plugins/i18n.ts +++ b/src/plugins/i18n.ts @@ -1,5 +1,6 @@ import Vue from 'vue' import VueI18n, { type Locale } from 'vue-i18n' +import { consola } from 'consola' import { SupportedLocales } from '@/globals' import messages from '@/locales/en.yaml' import { I18nLocales } from '@/dynamicImports' @@ -21,23 +22,25 @@ export const getAllLocales = (): Intl.LocalesArgument => { * Loads the starting locale for the user. */ export const getStartingLocale = () => { - const navigatorLocale = getNavigatorLocales()[0] - const countryCode = navigatorLocale.split(/-|_/)[0] - - if ( - countryCode && - SupportedLocales.some(locale => locale.code === countryCode) - ) { - return countryCode - } else { - return import.meta.env.VUE_APP_I18N_LOCALE || 'en' + for (const navigatorLocale of getNavigatorLocales()) { + const code = navigatorLocale.replace('_', '-') + const [language] = code.split('-') + + const supported = ( + SupportedLocales.find(locale => locale.code.toLowerCase() === code.toLowerCase()) ?? + SupportedLocales.find(locale => locale.code.toLowerCase() === language.toLowerCase()) + ) + + if (supported) { + return supported.code + } } -} -const startingLocale = getStartingLocale() + return import.meta.env.VUE_APP_I18N_LOCALE || 'en' +} const i18n = new VueI18n({ - locale: startingLocale, + locale: getStartingLocale(), fallbackLocale: import.meta.env.VUE_APP_I18N_FALLBACK_LOCALE || 'en', messages: {} }) @@ -45,33 +48,24 @@ const i18n = new VueI18n({ // Pre apply the en language for fallback. i18n.setLocaleMessage('en', messages) -const loadedLanguages: Locale[] = [] +export const loadLocaleMessagesAsync = async (locale?: Locale | null) => { + const resolvedLocale = locale ?? getStartingLocale() -export const loadLocaleMessagesAsync = async (locale: Locale) => { - // If already loaded, and currently selected. - if (loadedLanguages.length > 0 && i18n.locale === locale) { - return locale - } + if (!i18n.availableLocales.includes(resolvedLocale)) { + try { + i18n.setLocaleMessage(resolvedLocale, await I18nLocales[resolvedLocale]()) + } catch (error) { + consola.error(`[i18n] failed to load locale "${resolvedLocale}"`, error) - // If already loaded, but not the currently selected. - if (loadedLanguages.includes(locale)) { - i18n.locale = locale - return locale + return i18n.locale + } } - // Not loaded - try { - const messages = await I18nLocales[locale]() + i18n.locale = resolvedLocale - i18n.setLocaleMessage(locale, messages) - loadedLanguages.push(locale) - i18n.locale = locale - return locale - } catch { - return i18n.locale - } + return resolvedLocale } -loadLocaleMessagesAsync(startingLocale) +loadLocaleMessagesAsync() export default i18n diff --git a/src/plugins/vuetify.ts b/src/plugins/vuetify.ts index 89e5148eee..7311c5fbcb 100644 --- a/src/plugins/vuetify.ts +++ b/src/plugins/vuetify.ts @@ -3,12 +3,41 @@ import Vue from 'vue' import Vuetify from 'vuetify/lib' import { Ripple } from 'vuetify/lib/directives' import colors from 'vuetify/lib/util/colors' +import type { VuetifyLocale } from 'vuetify/types/services/lang' + +// Vuetify's own component translations, keyed by Fluidd locale code. +// Codes absent here (ie, `ta`) have no Vuetify translation and stay english. +const locales: Record Promise<{ default: VuetifyLocale }>> = { + af: () => import('vuetify/lib/locale/af'), + ar: () => import('vuetify/lib/locale/ar'), + cs: () => import('vuetify/lib/locale/cs'), + de: () => import('vuetify/lib/locale/de'), + en: () => import('vuetify/lib/locale/en'), + es: () => import('vuetify/lib/locale/es'), + fr: () => import('vuetify/lib/locale/fr'), + hu: () => import('vuetify/lib/locale/hu'), + it: () => import('vuetify/lib/locale/it'), + ja: () => import('vuetify/lib/locale/ja'), + ko: () => import('vuetify/lib/locale/ko'), + nl: () => import('vuetify/lib/locale/nl'), + pl: () => import('vuetify/lib/locale/pl'), + pt: () => import('vuetify/lib/locale/pt'), + pt_BR: () => import('vuetify/lib/locale/pt'), + ru: () => import('vuetify/lib/locale/ru'), + sl: () => import('vuetify/lib/locale/sl'), + sv: () => import('vuetify/lib/locale/sv'), + th: () => import('vuetify/lib/locale/th'), + tr: () => import('vuetify/lib/locale/tr'), + uk: () => import('vuetify/lib/locale/uk'), + 'zh-CN': () => import('vuetify/lib/locale/zh-Hans'), + 'zh-HK': () => import('vuetify/lib/locale/zh-Hant') +} Vue.use(Vuetify, { directives: { Ripple } }) -export default new Vuetify({ +const vuetify = new Vuetify({ breakpoint: { mobileBreakpoint: 'xs' }, @@ -45,3 +74,28 @@ export default new Vuetify({ } } }) + +/** + * Loads and applies the Vuetify component translations for a given locale. + */ +export const loadVuetifyLocaleAsync = async (locale: string) => { + const { lang } = vuetify.framework + + const load = locales[locale] + + if (!load) { + lang.current = 'en' + + return + } + + if (!(locale in lang.locales)) { + const { default: messages } = await load() + + Vue.set(lang.locales, locale, messages) + } + + lang.current = locale +} + +export default vuetify diff --git a/src/store/config/actions.ts b/src/store/config/actions.ts index dc3cf40269..3ff8f1769a 100644 --- a/src/store/config/actions.ts +++ b/src/store/config/actions.ts @@ -1,9 +1,9 @@ -import vuetify from '@/plugins/vuetify' +import vuetify, { loadVuetifyLocaleAsync } from '@/plugins/vuetify' import type { ActionTree } from 'vuex' import type { ConfigState, SaveByPath, InitConfig, InstanceConfig, TemperaturePreset, UiSettings, ThemeConfig, ConfiguredTableHeader } from './types' import type { RootState } from '../types' import { SocketActions } from '@/api/socketActions' -import { loadLocaleMessagesAsync, getStartingLocale } from '@/plugins/i18n' +import { loadLocaleMessagesAsync } from '@/plugins/i18n' import { Waits } from '@/globals' import type { FileFilterType } from '../files/types' import { TinyColor } from '@ctrl/tinycolor' @@ -54,31 +54,29 @@ export const actions = { * Sets, and saves a locale change. */ async onLocaleChange ({ dispatch, state }, payload: string) { - // Set the correct language. - // vuetify.framework.lang.current = state.uiSettings.general.locale - - // Add the wait. dispatch('wait/addWait', Waits.onLoadLanguage, { root: true }) - // Grab the browsers starting locale. - const startingLocale = getStartingLocale() - - // Set the locale. If its set as default, use the starting locale. - const locale = (payload !== 'default') - ? await loadLocaleMessagesAsync(payload) - : await loadLocaleMessagesAsync(startingLocale) - - // If the locale doesn't match what we have in settings, update it. - if ( - state.uiSettings.general.locale !== payload - ) { - dispatch('saveByPath', { - path: 'uiSettings.general.locale', - value: (payload !== 'default') ? locale : payload, - server: true - }) + try { + const locale = await loadLocaleMessagesAsync( + payload === 'default' + ? null + : payload + ) + + await loadVuetifyLocaleAsync(locale) + + if (state.uiSettings.general.locale !== payload) { + dispatch('saveByPath', { + path: 'uiSettings.general.locale', + value: payload === 'default' + ? payload + : locale, + server: true + }) + } + } finally { + dispatch('wait/removeWait', Waits.onLoadLanguage, { root: true }) } - dispatch('wait/removeWait', Waits.onLoadLanguage, { root: true }) }, /** diff --git a/src/typings/vuetify.d.ts b/src/typings/vuetify.d.ts index 794bb4702e..a515e4f943 100644 --- a/src/typings/vuetify.d.ts +++ b/src/typings/vuetify.d.ts @@ -119,3 +119,11 @@ declare module 'vuetify/lib/components' { delimiters: string[] } } + +declare module 'vuetify/lib/locale/*' { + import type { VuetifyLocale } from 'vuetify/types/services/lang' + + const locale: VuetifyLocale + + export default locale +}