feat: dynamic languages (#27869)

Co-authored-by: xantin <github@xantin.be>
This commit is contained in:
Jason Rasmussen
2026-04-16 12:37:37 -04:00
committed by GitHub
parent 88bce52042
commit 6aadb7b5bd
6 changed files with 120 additions and 127 deletions
@@ -1,9 +1,9 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import Combobox from '$lib/components/shared-components/combobox.svelte';
import { defaultLang, langs } from '$lib/constants';
import { defaultLang } from '$lib/constants';
import { lang } from '$lib/stores/preferences.store';
import { getClosestAvailableLocale, langCodes } from '$lib/utils/i18n';
import { getClosestAvailableLocale, langCodes, langs } from '$lib/utils/i18n';
import { Label, Text } from '@immich/ui';
import { locale as i18nLocale, t } from 'svelte-i18n';
@@ -13,14 +13,7 @@
let { showSettingDescription = false }: Props = $props();
const langOptions = langs
.map((lang) => ({ label: lang.name, value: lang.code }))
.sort((a, b) => {
if (b.label.startsWith('Development')) {
return -1;
}
return a.label.localeCompare(b.label);
});
const langOptions = langs.map((lang) => ({ label: lang.name, value: lang.code }));
const defaultLangOption = { label: defaultLang.name, value: defaultLang.code };
+1 -100
View File
@@ -229,114 +229,15 @@ export const locales = [
{ code: 'zu-ZA', name: 'Zulu (South Africa)' },
];
interface Lang {
export interface Lang {
name: string;
code: string;
loader: () => Promise<{ default: object }>;
rtl?: boolean;
weblateCode?: string;
}
export const defaultLang: Lang = { name: 'English', code: 'en', loader: () => import('$i18n/en.json') };
export const langs: Lang[] = [
{ name: 'Afrikaans', code: 'af', loader: () => import('$i18n/af.json') },
{ name: 'Arabic', code: 'ar', loader: () => import('$i18n/ar.json'), rtl: true },
{ name: 'Azerbaijani', code: 'az', loader: () => import('$i18n/az.json'), rtl: true },
{ name: 'Belarusian', code: 'be', loader: () => import('$i18n/be.json') },
{ name: 'Bulgarian', code: 'bg', loader: () => import('$i18n/bg.json') },
{ name: 'Bislama', code: 'bi', loader: () => import('$i18n/bi.json') },
{ name: 'Bengali', code: 'bn', loader: () => import('$i18n/bn.json') },
{ name: 'Breton', code: 'br', loader: () => import('$i18n/br.json') },
{ name: 'Catalan', code: 'ca', loader: () => import('$i18n/ca.json') },
{ name: 'Czech', code: 'cs', loader: () => import('$i18n/cs.json') },
{ name: 'Chuvash', code: 'cv', loader: () => import('$i18n/cv.json') },
{ name: 'Danish', code: 'da', loader: () => import('$i18n/da.json') },
{ name: 'German', code: 'de', loader: () => import('$i18n/de.json') },
{ name: 'German (Switzerland)', code: 'de-CH', weblateCode: 'de_CH', loader: () => import('$i18n/de_CH.json') },
defaultLang,
{ name: 'Greek', code: 'el', loader: () => import('$i18n/el.json') },
{ name: 'Esperanto', code: 'eo', loader: () => import('$i18n/eo.json') },
{ name: 'Spanish', code: 'es', loader: () => import('$i18n/es.json') },
{ name: 'Estonian', code: 'et', loader: () => import('$i18n/et.json') },
{ name: 'Basque', code: 'eu', loader: () => import('$i18n/eu.json') },
{ name: 'Persian', code: 'fa', loader: () => import('$i18n/fa.json'), rtl: true },
{ name: 'Finnish', code: 'fi', loader: () => import('$i18n/fi.json') },
{ name: 'Filipino', code: 'fil', loader: () => import('$i18n/fil.json') },
{ name: 'French', code: 'fr', loader: () => import('$i18n/fr.json') },
{ name: 'Irish', code: 'ga', loader: () => import('$i18n/ga.json') },
{ name: 'Galician', code: 'gl', loader: () => import('$i18n/gl.json') },
{ name: 'Alemannic', code: 'gsw', loader: () => import('$i18n/gsw.json') },
{ name: 'Gujarati', code: 'gu', loader: () => import('$i18n/gu.json') },
{ name: 'Hebrew', code: 'he', loader: () => import('$i18n/he.json'), rtl: true },
{ name: 'Hindi', code: 'hi', loader: () => import('$i18n/hi.json') },
{ name: 'Croatian', code: 'hr', loader: () => import('$i18n/hr.json') },
{ name: 'Hungarian', code: 'hu', loader: () => import('$i18n/hu.json') },
{ name: 'Armenian', code: 'hy', loader: () => import('$i18n/hy.json') },
{ name: 'Indonesian', code: 'id', loader: () => import('$i18n/id.json') },
{ name: 'Icelandic', code: 'is', loader: () => import('$i18n/is.json') },
{ name: 'Italian', code: 'it', loader: () => import('$i18n/it.json') },
{ name: 'Japanese', code: 'ja', loader: () => import('$i18n/ja.json') },
{ name: 'Georgian', code: 'ka', loader: () => import('$i18n/ka.json') },
{ name: 'Kazakh', code: 'kk', loader: () => import('$i18n/kk.json') },
{ name: 'Khmer (Central)', code: 'km', loader: () => import('$i18n/km.json') },
{ name: 'Kurdish (Northern)', code: 'kmr', loader: () => import('$i18n/kmr.json'), rtl: true },
{ name: 'Kannada', code: 'kn', loader: () => import('$i18n/kn.json') },
{ name: 'Korean', code: 'ko', loader: () => import('$i18n/ko.json') },
{ name: 'Luxembourgish', code: 'lb', loader: () => import('$i18n/lb.json') },
{ name: 'Lithuanian', code: 'lt', loader: () => import('$i18n/lt.json') },
{ name: 'Latvian', code: 'lv', loader: () => import('$i18n/lv.json') },
{ name: 'Malay (Pattani)', code: 'mfa', loader: () => import('$i18n/mfa.json') },
{ name: 'Macedonian', code: 'mk', loader: () => import('$i18n/mk.json') },
{ name: 'Malayalam', code: 'ml', loader: () => import('$i18n/ml.json') },
{ name: 'Mongolian', code: 'mn', loader: () => import('$i18n/mn.json') },
{ name: 'Marathi', code: 'mr', loader: () => import('$i18n/mr.json') },
{ name: 'Malay', code: 'ms', loader: () => import('$i18n/ms.json') },
{ name: 'Norwegian Bokmål', code: 'nb-NO', weblateCode: 'nb_NO', loader: () => import('$i18n/nb_NO.json') },
{ name: 'Dutch', code: 'nl', loader: () => import('$i18n/nl.json') },
{ name: 'Norwegian Nynorsk', code: 'nn', loader: () => import('$i18n/nn.json') },
{ name: 'Punjabi', code: 'pa', loader: () => import('$i18n/pa.json') },
{ name: 'Polish', code: 'pl', loader: () => import('$i18n/pl.json') },
{ name: 'Portuguese', code: 'pt', loader: () => import('$i18n/pt.json') },
{ name: 'Portuguese (Brazil) ', code: 'pt-BR', weblateCode: 'pt_BR', loader: () => import('$i18n/pt_BR.json') },
{ name: 'Romanian', code: 'ro', loader: () => import('$i18n/ro.json') },
{ name: 'Russian', code: 'ru', loader: () => import('$i18n/ru.json') },
{ name: 'Sinhala', code: 'si', loader: () => import('$i18n/si.json') },
{ name: 'Slovak', code: 'sk', loader: () => import('$i18n/sk.json') },
{ name: 'Slovenian', code: 'sl', loader: () => import('$i18n/sl.json') },
{ name: 'Albanian', code: 'sq', loader: () => import('$i18n/sq.json') },
{
name: 'Serbian (Cyrillic)',
code: 'sr-Cyrl',
weblateCode: 'sr_Cyrl',
loader: () => import('$i18n/sr_Cyrl.json'),
},
{ name: 'Serbian (Latin)', code: 'sr-Latn', weblateCode: 'sr_Latn', loader: () => import('$i18n/sr_Latn.json') },
{ name: 'Swedish', code: 'sv', loader: () => import('$i18n/sv.json') },
{ name: 'Tamil', code: 'ta', loader: () => import('$i18n/ta.json') },
{ name: 'Telugu', code: 'te', loader: () => import('$i18n/te.json') },
{ name: 'Thai', code: 'th', loader: () => import('$i18n/th.json') },
{ name: 'Turkish', code: 'tr', loader: () => import('$i18n/tr.json') },
{ name: 'Ukrainian', code: 'uk', loader: () => import('$i18n/uk.json') },
{ name: 'Urdu', code: 'ur', loader: () => import('$i18n/ur.json'), rtl: true },
{ name: 'Uzbek', code: 'uz', loader: () => import('$i18n/uz.json') },
{ name: 'Vietnamese', code: 'vi', loader: () => import('$i18n/vi.json') },
{ name: 'Cantonese (Traditional Han script)', code: 'yue_Hant', loader: () => import('$i18n/yue_Hant.json') },
{
name: 'Chinese (Traditional)',
code: 'zh-TW',
weblateCode: 'zh_Hant',
loader: () => import('$i18n/zh_Hant.json'),
},
{
name: 'Chinese (Simplified)',
code: 'zh-CN',
weblateCode: 'zh_Hans',
loader: () => import('$i18n/zh_Hans.json'),
},
{ name: 'Development (keys only)', code: 'dev', loader: () => Promise.resolve({ default: {} }) },
];
export enum ImmichProduct {
Client = 'immich-client',
Server = 'immich-server',
+2 -3
View File
@@ -1,5 +1,4 @@
import { langs } from '$lib/constants';
import { getClosestAvailableLocale } from '$lib/utils/i18n';
import { getClosestAvailableLocale, langs } from '$lib/utils/i18n';
import { readFileSync, readdirSync } from 'node:fs';
describe('i18n', () => {
@@ -12,7 +11,7 @@ describe('i18n', () => {
}
const code = filename.replaceAll('.json', '');
const item = langs.find((lang) => lang.weblateCode === code || lang.code === code);
const item = langs.find((lang) => lang.code === code);
expect(item, `${filename} has no loader`).toBeDefined();
if (!item) {
return;
@@ -1,6 +1,6 @@
import { langs } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { lang } from '$lib/stores/preferences.store';
import { langs } from '$lib/utils/i18n';
class LanguageManager {
constructor() {
+2 -1
View File
@@ -1,8 +1,9 @@
import { defaultLang, langs, locales } from '$lib/constants';
import { defaultLang, locales } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { langs } from '$lib/utils/i18n';
import {
AssetMediaSize,
AssetTypeEnum,
+111 -12
View File
@@ -1,8 +1,8 @@
import { langs } from '$lib/constants';
import type { Lang } from '$lib/constants';
import { locale, t, waitLocale } from 'svelte-i18n';
import { get, type Unsubscriber } from 'svelte/store';
export async function getFormatter() {
export const getFormatter = async () => {
let unsubscribe: Unsubscriber | undefined;
await new Promise((resolve) => {
unsubscribe = locale.subscribe((value) => value && resolve(value));
@@ -11,23 +11,122 @@ export async function getFormatter() {
await waitLocale();
return get(t);
}
};
const modules = import.meta.glob('$i18n/*.json');
const fileCodes = Object.keys(modules)
.map((path) => path.match(/\/(\w+)\.json$/)?.[1])
.filter(Boolean) as string[];
const convertBCP47 = (code: string) => code.replace('_', '-');
export const langCodes = fileCodes.map((code) => convertBCP47(code));
// https://github.com/kaisermann/svelte-i18n/blob/780932a3e1270d521d348aac8ba03be9df309f04/src/runtime/stores/locale.ts#L11
function getSubLocales(refLocale: string) {
return refLocale
const getSubLocales = (locale: string) => {
return locale
.split('-')
.map((_, i, arr) => arr.slice(0, i + 1).join('-'))
.reverse();
}
};
export function getClosestAvailableLocale(locales: readonly string[], allLocales: readonly string[]) {
export const getClosestAvailableLocale = (locales: readonly string[], allLocales: readonly string[]) => {
const allLocalesSet = new Set(allLocales);
return locales.find((locale) => getSubLocales(locale).some((subLocale) => allLocalesSet.has(subLocale)));
}
};
export const langCodes = langs.map((lang) => lang.code);
export const getPreferredLocale = () => getClosestAvailableLocale(navigator.languages, langCodes);
export function getPreferredLocale() {
return getClosestAvailableLocale(navigator.languages, langCodes);
}
const rtlCodes = new Set([
'ae',
'aeb',
'aii',
'ajp',
'apc',
'apd',
'ar',
'ar_BH',
'ar_DZ',
'ar_EG',
'ar_KW',
'ar_LY',
'ar_MA',
'ar_SA',
'ar_YE',
'ara',
'arc',
'arq',
'ars',
'arz',
'ave',
'bal',
'bcc',
'bgn',
'bqi',
'ckb',
'ckb_IR',
'dv',
'egy',
'fa',
'fa_AF',
'fas',
'glk',
'ha',
'he',
'heb',
'khw',
'kmr',
'ks',
'ku',
'lrc',
'luz',
'ms_Arab',
'mzn',
'nqo',
'pa_PK',
'pal',
'per',
'phn',
'pnb',
'prs',
'ps',
'rhg',
'sam',
'sd',
'sdh',
'skr',
'syc',
'syr',
'ug',
'ur',
'ur_IN',
'urd',
'yi',
]);
const capitalize = (string: string) =>
string
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
const nonIntlNames: Record<string, string> = {
mfa: 'Malay (Pattani)',
bi: 'Bislama',
};
const getLanguageName = (code: string) =>
nonIntlNames[code] ?? capitalize(new Intl.DisplayNames([code], { type: 'language' }).of(code) ?? code);
export const langs: Lang[] = [
...fileCodes
.map((code) => ({
name: getLanguageName(convertBCP47(code)),
code,
loader: () => import(`$i18n/${code}.json`),
rtl: rtlCodes.has(code),
}))
.sort((a, b) => a.code.localeCompare(b.code)),
{ name: 'Development (keys only)', code: 'dev', loader: () => Promise.resolve({ default: {} }) },
];