Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions docs/docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 1 addition & 2 deletions docs/docs/features/localization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions src/components/settings/GeneralSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
hide-details="auto"
:items="supportedLocales"
:value="locale"
:loading="hasWait($waits.onLoadLanguage)"
item-text="name"
item-value="code"
@change="setLocale"
Expand Down
67 changes: 67 additions & 0 deletions src/plugins/__tests__/i18n.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
41 changes: 41 additions & 0 deletions src/plugins/__tests__/vuetify.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
62 changes: 28 additions & 34 deletions src/plugins/i18n.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -21,57 +22,50 @@ 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: {}
})

// 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
56 changes: 55 additions & 1 deletion src/plugins/vuetify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, () => 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'
},
Expand Down Expand Up @@ -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
46 changes: 22 additions & 24 deletions src/store/config/actions.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 })
},

/**
Expand Down
Loading