diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts index f1388cd4..ff9311bd 100644 --- a/web/src/hooks.server.ts +++ b/web/src/hooks.server.ts @@ -9,6 +9,7 @@ import { sequence } from '@sveltejs/kit/hooks' import { MeiliSearch } from 'meilisearch' import { locale } from 'svelte-i18n' import type { Actor } from '$lib/models/activitypub/actor' +import { normalizeLocale } from '$lib/i18n/locales' function csrf(allowedPaths: string[]): Handle { @@ -109,12 +110,14 @@ const auth: Handle = async ({ event, resolve }) => { } event.locals.settings = settings - const lang = settings?.language ?? event.request.headers.get('accept-language')?.split(',')[0] + const langHeader = event.request.headers.get('accept-language')?.split(',')[0] + const lang = settings?.language ?? langHeader if (lang) { - locale.set(lang) + const normalizedLocale = normalizeLocale(lang) + locale.set(normalizedLocale) if (pb.authStore.record) { - pb.authStore.record!.language = lang; + pb.authStore.record!.language = normalizedLocale; } } diff --git a/web/src/lib/i18n/index.ts b/web/src/lib/i18n/index.ts index a263ed13..67fa4f9b 100644 --- a/web/src/lib/i18n/index.ts +++ b/web/src/lib/i18n/index.ts @@ -1,26 +1,20 @@ import { browser } from '$app/environment'; import { getPb } from '$lib/pocketbase'; import { init, register } from 'svelte-i18n'; +import { defaultLocale, LOCALE_LOADERS, normalizeLocale } from './locales'; -const defaultLocale = 'en' -register('cs', () => import('./locales/cs.json')) -register('en', () => import('./locales/en.json')) -register('de', () => import('./locales/de.json')) -register('es', () => import('./locales/es.json')) -register('eu', () => import('./locales/eu.json')) -register('fr', () => import('./locales/fr.json')) -register('hu', () => import('./locales/hu.json')) -register('it', () => import('./locales/it.json')) -register('nl', () => import('./locales/nl.json')) -register('no', () => import('./locales/no.json')) -register('pl', () => import('./locales/pl.json')) -register('pt', () => import('./locales/pt.json')) -register('ru', () => import('./locales/ru.json')) -register('zh', () => import('./locales/zh.json')) +for (const [localeKey, loader] of Object.entries(LOCALE_LOADERS)) { + register(localeKey, loader) +} + +const userLang = browser ? getPb().authStore.record?.language : null; +const navigatorLang = browser ? window.navigator.language : null; + +const initial = normalizeLocale(userLang ?? navigatorLang ?? defaultLocale); init({ fallbackLocale: defaultLocale, - initialLocale: browser ? getPb().authStore.record?.language ?? window.navigator.language : defaultLocale, + initialLocale: initial, formats: { date: { monthName: { month: 'long' } @@ -28,4 +22,4 @@ init({ number: {}, time: {} }, -}) \ No newline at end of file +}) diff --git a/web/src/lib/i18n/locales.ts b/web/src/lib/i18n/locales.ts new file mode 100644 index 00000000..50b08038 --- /dev/null +++ b/web/src/lib/i18n/locales.ts @@ -0,0 +1,54 @@ +type LocaleMessages = Record; +type LocaleModule = { default: LocaleMessages }; +type LocaleLoader = () => Promise; + +const localeModules = import.meta.glob("./locales/*.json"); + +const localeEntries = Object.entries(localeModules) + .map(([path, loadModule]) => { + const match = path.match(/\/([a-z]{2,5}(?:-[a-z]{2,4})?)\.json$/i); + if (!match) return null; + + const locale = match[1].toLowerCase(); + const loader: LocaleLoader = async () => (await loadModule()).default; + return [locale, loader] as const; + }) + .filter((entry): entry is readonly [string, LocaleLoader] => entry !== null); + +export const LOCALE_LOADERS: Record = Object.fromEntries( + localeEntries, +); + +export const SUPPORTED_LOCALES = Object.keys(LOCALE_LOADERS).sort(); +export type SupportedLocale = string; + +export const defaultLocale: SupportedLocale = SUPPORTED_LOCALES.includes("en") + ? "en" + : (SUPPORTED_LOCALES[0] ?? "en"); + +export function normalizeLocale(raw: string | null | undefined): SupportedLocale { + if (!raw) return defaultLocale; + + // remove spaces and any ";q=0.7" part + let tag = raw.trim().split(";")[0]; + + // Turn "de_CH" into "de-CH" + tag = tag.replace("_", "-"); + + // Lowercase language, uppercase region if present + const parts = tag.split("-"); + if (parts.length === 2) { + tag = `${parts[0].toLowerCase()}-${parts[1].toUpperCase()}`; + } else { + tag = tag.toLowerCase(); + } + + // Map "de-CH" -> "de", "en-US" -> "en", etc. + const base = tag.split("-")[0]; + + if (SUPPORTED_LOCALES.includes(base)) { + return base; + } + + return defaultLocale; +}