feat: implement internationalization with locale management and configuration from url

This commit is contained in:
@milesibastos
2025-02-04 10:55:28 -03:00
parent 19fdc3b5b7
commit dafe347973
14 changed files with 198 additions and 154 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"tabWidth": 2,
"useTabs": false,
"printWidth": 120
}
+45 -62
View File
@@ -1,70 +1,53 @@
<script lang="ts">
import { locale, isLoading } from "svelte-i18n";
import { browser } from "$app/environment";
import type { LocaleConfig } from "$lib/i18n/types";
import { fade } from "svelte/transition";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { config } from "$lib/i18n";
const supportedLocales: LocaleConfig[] = [
{ code: "en", label: "English" },
{ code: "es", label: "Español" }
];
const localeLabels: Record<string, string> = {
en: "English",
es: "Español",
// ...add other locale labels
};
async function setLocale(code: LocaleConfig['code']) {
try {
await locale.set(code);
if (browser) {
localStorage.setItem("locale", code);
}
} catch (error) {
console.error('Failed to set locale:', error);
$: currentLocale = $page.params.locale || config.defaultLocale;
function getLocalePath(newLocale: string): string {
const currentPath = $page.url.pathname;
const segments = currentPath.split("/").filter(Boolean);
if (newLocale === config.defaultLocale) {
return segments.length > 1 ? `/${segments.slice(1).join("/")}` : "/";
}
return currentLocale === config.defaultLocale
? `/${newLocale}${currentPath}`
: currentPath.replace(`/${currentLocale}`, `/${newLocale}`);
}
async function switchLocale(newLocale: string) {
const newPath = getLocalePath(newLocale);
await goto(newPath, { invalidateAll: true });
}
</script>
<div class="locale-switcher" role="group" aria-label="Language selector">
{#each supportedLocales as { code, label }}
<button
class="locale-button"
class:active={$locale === code}
on:click={() => setLocale(code)}
aria-label={`Switch to ${label}`}
disabled={$isLoading}
aria-current={$locale === code}
>
{#if $isLoading && $locale === code}
<span class="loading">...</span>
{/if}
{label}
</button>
{/each}
<div class="relative" role="group" aria-label="Language selector">
<select
class="appearance-none w-full py-2 px-4 pr-8 border border-gray-300 rounded-md bg-white cursor-pointer text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
value={currentLocale}
on:change={(e) => switchLocale(e.currentTarget.value)}
aria-label="Select language"
transition:fade
>
{#each config.availableLocales as locale}
<option value={locale}>
{localeLabels[locale] || locale.toUpperCase()}
</option>
{/each}
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" />
</svg>
</div>
</div>
<style>
.locale-switcher {
display: flex;
gap: 0.5rem;
}
.locale-button {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
background: transparent;
cursor: pointer;
position: relative;
}
.locale-button:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.locale-button.active {
background: #ddd;
}
.loading {
position: absolute;
left: 50%;
transform: translateX(-50%);
}
</style>
+27 -5
View File
@@ -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);
+15
View File
@@ -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);
}
+31 -36
View File
@@ -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<void> | 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";
+3
View File
@@ -0,0 +1,3 @@
import { writable } from "svelte/store";
export const locale = writable("en");
+4 -3
View File
@@ -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[];
}
+5
View File
@@ -0,0 +1,5 @@
import { isValidLocale } from "$lib/i18n";
export const match = (param: string) => {
return isValidLocale(param);
};
-18
View File
@@ -1,18 +0,0 @@
<script lang="ts">
import { isLoading } from "svelte-i18n";
import "../app.css";
</script>
{#if $isLoading}
<div class="loading">Loading...</div>
{:else}
<slot />
{/if}
<style>
.loading {
display: grid;
place-items: center;
height: 100vh;
}
</style>
+2 -15
View File
@@ -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 {};
};
-15
View File
@@ -1,15 +0,0 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import LocaleSwitcher from "../components/LocaleSwitcher.svelte";
</script>
<h1 class="text-3xl font-bold underline">{$_("greeting")}</h1>
<p>{$_("farewell")}</p>
<LocaleSwitcher />
<style>
@reference "tailwindcss/theme";
:global(html) {
background-color: theme(--color-gray-100);
}
</style>
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import { isLoading, locale } from "svelte-i18n";
import { page } from "$app/stores";
import "../../app.css";
$: {
const currentLocale = $page.data.locale;
if (currentLocale) {
locale.set(currentLocale);
}
}
</script>
{#if $isLoading}
<div class="grid place-items-center h-screen">Loading...</div>
{:else}
<slot />
{/if}
+22
View File
@@ -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 };
};
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import LocaleSwitcher from "../../components/LocaleSwitcher.svelte";
import type { PageData } from './$types';
export let data: PageData;
</script>
<div class="p-4">
<LocaleSwitcher />
<h1 class="text-3xl font-bold underline mt-4">{$_("greeting")}</h1>
<p class="mt-2">{$_("farewell")}</p>
<p class="mt-2">Current locale: {data.locale}</p>
</div>
<style>
@reference "tailwindcss/theme";
:global(html) {
background-color: theme(--color-gray-100);
}
</style>