From dafe347973feeb3755d4bb1bc97ce6ebe90340cb Mon Sep 17 00:00:00 2001 From: "@milesibastos" Date: Tue, 4 Feb 2025 09:05:20 -0300 Subject: [PATCH] feat: implement internationalization with locale management and configuration from url --- .prettierrc | 5 ++ src/components/LocaleSwitcher.svelte | 107 +++++++++++---------------- src/hooks.server.ts | 32 ++++++-- src/lib/i18n/config.ts | 15 ++++ src/lib/i18n/index.ts | 67 ++++++++--------- src/lib/i18n/store.ts | 3 + src/lib/i18n/types.ts | 7 +- src/params/locale.ts | 5 ++ src/routes/+layout.svelte | 18 ----- src/routes/+layout.ts | 17 +---- src/routes/+page.svelte | 15 ---- src/routes/[[locale]]/+layout.svelte | 18 +++++ src/routes/[[locale]]/+layout.ts | 22 ++++++ src/routes/[[locale]]/+page.svelte | 21 ++++++ 14 files changed, 198 insertions(+), 154 deletions(-) create mode 100644 .prettierrc create mode 100644 src/lib/i18n/config.ts create mode 100644 src/lib/i18n/store.ts create mode 100644 src/params/locale.ts delete mode 100644 src/routes/+layout.svelte delete mode 100644 src/routes/+page.svelte create mode 100644 src/routes/[[locale]]/+layout.svelte create mode 100644 src/routes/[[locale]]/+layout.ts create mode 100644 src/routes/[[locale]]/+page.svelte diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..054d599c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "tabWidth": 2, + "useTabs": false, + "printWidth": 120 +} diff --git a/src/components/LocaleSwitcher.svelte b/src/components/LocaleSwitcher.svelte index 2415008c..1940dae6 100644 --- a/src/components/LocaleSwitcher.svelte +++ b/src/components/LocaleSwitcher.svelte @@ -1,70 +1,53 @@ -
- {#each supportedLocales as { code, label }} - - {/each} +
+ +
+ + + +
- - diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 452cf0b1..d5df0857 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,10 +1,32 @@ -import { locale } from "svelte-i18n"; +import { sequence } from "@sveltejs/kit/hooks"; +import { init, locale } from "svelte-i18n"; import type { Handle } from "@sveltejs/kit"; +import { config, hasTranslations, isValidLocale } from "$lib/i18n"; -export const handle: Handle = async ({ event, resolve }) => { - const lang = event.request.headers.get("accept-language")?.split(",")[0]; - if (lang) { - locale.set(lang); +const i18n: Handle = async ({ event, resolve }) => { + const urlLocale = event.params.locale; + const acceptLang = event.request.headers.get("accept-language")?.split(",")[0]?.split("-")[0]; + + let resolvedLocale = config.defaultLocale; + + if (urlLocale && isValidLocale(urlLocale) && hasTranslations(urlLocale)) { + resolvedLocale = urlLocale; + } else if (acceptLang && isValidLocale(acceptLang) && hasTranslations(acceptLang)) { + resolvedLocale = acceptLang; } + + // Initialize i18n for SSR if not already initialized + try { + locale.set(resolvedLocale); + } catch { + init({ + fallbackLocale: config.defaultLocale, + initialLocale: resolvedLocale, + }); + } + + event.locals.locale = resolvedLocale; return resolve(event); }; + +export const handle = sequence(i18n); diff --git a/src/lib/i18n/config.ts b/src/lib/i18n/config.ts new file mode 100644 index 00000000..3dd00ac8 --- /dev/null +++ b/src/lib/i18n/config.ts @@ -0,0 +1,15 @@ +import type { LocaleConfig, SupportedLocale } from "./types"; + +export const config: LocaleConfig = { + supportedLocales: ["en", "es", "ca", "fr", "de"], + defaultLocale: "en", + availableLocales: ["en", "es"], // locales with actual translations +}; + +export function isValidLocale(locale: string): locale is SupportedLocale { + return config.supportedLocales.includes(locale as SupportedLocale); +} + +export function hasTranslations(locale: SupportedLocale): boolean { + return config.availableLocales.includes(locale); +} diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index 72d5a30d..cd41f14a 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -1,42 +1,37 @@ import { browser } from "$app/environment"; -import { init, register } from "svelte-i18n"; +import { init, register, getLocaleFromNavigator } from "svelte-i18n"; +import { config, hasTranslations } from "./config"; import type { SupportedLocale } from "./types"; -const defaultLocale: SupportedLocale = "es"; -const supportedLocales: SupportedLocale[] = ["en", "es"]; +let initializationPromise: Promise | null = null; -register("en", () => import("./locales/en.json").catch(() => ({}))); -register("es", () => import("./locales/es.json").catch(() => ({}))); - -/** - * Determines the initial locale based on the following priority: - * 1. Previously saved locale in localStorage - * 2. Browser's language - * 3. Default locale - */ -const getInitialLocale = (): SupportedLocale => { - if (!browser) return defaultLocale; - - try { - const savedLocale = localStorage.getItem("locale") as SupportedLocale; - if (savedLocale && supportedLocales.includes(savedLocale)) { - return savedLocale; - } - - const browserLocale = window.navigator.language.split( - "-", - )[0] as SupportedLocale; - return supportedLocales.includes(browserLocale) - ? browserLocale - : defaultLocale; - } catch { - return defaultLocale; - } -}; - -init({ - fallbackLocale: defaultLocale, - initialLocale: getInitialLocale(), +// Pre-register all available locales +config.availableLocales.forEach((locale) => { + register(locale, () => import(`./locales/${locale}.json`).then((module) => module.default)); }); -export { supportedLocales, defaultLocale }; +export function getBrowserLocale(): SupportedLocale | null { + if (!browser) return null; + const navigatorLocale = getLocaleFromNavigator()?.split("-")[0] as SupportedLocale; + return hasTranslations(navigatorLocale) ? navigatorLocale : null; +} + +export async function initI18n() { + if (initializationPromise) return initializationPromise; + + initializationPromise = (async () => { + const initLocale = browser ? getBrowserLocale() || config.defaultLocale : config.defaultLocale; + + init({ + fallbackLocale: config.defaultLocale, + initialLocale: initLocale, + }); + })(); + + return initializationPromise; +} + +// Remove loadLocaleData as it's no longer needed +export * from "./config"; +export { config, isValidLocale, hasTranslations } from "./config"; +export type { LocaleConfig, SupportedLocale } from "./types"; diff --git a/src/lib/i18n/store.ts b/src/lib/i18n/store.ts new file mode 100644 index 00000000..0e83a23b --- /dev/null +++ b/src/lib/i18n/store.ts @@ -0,0 +1,3 @@ +import { writable } from "svelte/store"; + +export const locale = writable("en"); diff --git a/src/lib/i18n/types.ts b/src/lib/i18n/types.ts index c2c715fb..805c278f 100644 --- a/src/lib/i18n/types.ts +++ b/src/lib/i18n/types.ts @@ -1,6 +1,7 @@ -export type SupportedLocale = "en" | "es"; +export type SupportedLocale = "en" | "es" | "ca" | "fr" | "de"; export interface LocaleConfig { - code: SupportedLocale; - label: string; + supportedLocales: SupportedLocale[]; + defaultLocale: SupportedLocale; + availableLocales: SupportedLocale[]; } diff --git a/src/params/locale.ts b/src/params/locale.ts new file mode 100644 index 00000000..ce43d099 --- /dev/null +++ b/src/params/locale.ts @@ -0,0 +1,5 @@ +import { isValidLocale } from "$lib/i18n"; + +export const match = (param: string) => { + return isValidLocale(param); +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte deleted file mode 100644 index c0e99765..00000000 --- a/src/routes/+layout.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - -{#if $isLoading} -
Loading...
-{:else} - -{/if} - - diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 2b76143f..0ea7f440 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -1,20 +1,7 @@ -import { locale, waitLocale } from "svelte-i18n"; -import { browser } from "$app/environment"; -import "$lib/i18n"; import type { LayoutLoad } from "./$types"; -import { defaultLocale } from "$lib/i18n"; +import { initI18n } from "$lib/i18n"; export const load: LayoutLoad = async () => { - try { - if (browser) { - await locale.set(window.navigator.language); - } - await waitLocale(); - } catch (error) { - console.error("Failed to load locale:", error); - if (browser) { - await locale.set(defaultLocale); - } - } + await initI18n(); return {}; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte deleted file mode 100644 index ec8182cb..00000000 --- a/src/routes/+page.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - -

{$_("greeting")}

-

{$_("farewell")}

- - - diff --git a/src/routes/[[locale]]/+layout.svelte b/src/routes/[[locale]]/+layout.svelte new file mode 100644 index 00000000..57f227d8 --- /dev/null +++ b/src/routes/[[locale]]/+layout.svelte @@ -0,0 +1,18 @@ + + +{#if $isLoading} +
Loading...
+{:else} + +{/if} diff --git a/src/routes/[[locale]]/+layout.ts b/src/routes/[[locale]]/+layout.ts new file mode 100644 index 00000000..0f653e58 --- /dev/null +++ b/src/routes/[[locale]]/+layout.ts @@ -0,0 +1,22 @@ +import { redirect } from "@sveltejs/kit"; +import type { LayoutLoad } from "./$types"; +import { getBrowserLocale, hasTranslations, isValidLocale } from "$lib/i18n"; +import { config } from "$lib/i18n/config"; + +export const load: LayoutLoad = async ({ params, url, parent }) => { + const parentData = await parent(); + const urlLocale = params.locale; + + if (!urlLocale) { + const browserLocale = getBrowserLocale(); + const locale = browserLocale || config.defaultLocale; + return { locale, ...parentData }; + } + + if (!isValidLocale(urlLocale) || !hasTranslations(urlLocale)) { + const redirectUrl = url.pathname.replace(`/${urlLocale}`, ""); + throw redirect(301, redirectUrl); + } + + return { locale: urlLocale, ...parentData }; +}; diff --git a/src/routes/[[locale]]/+page.svelte b/src/routes/[[locale]]/+page.svelte new file mode 100644 index 00000000..af9c9881 --- /dev/null +++ b/src/routes/[[locale]]/+page.svelte @@ -0,0 +1,21 @@ + + +
+ +

{$_("greeting")}

+

{$_("farewell")}

+

Current locale: {data.locale}

+
+ +