Logo VelaStack

Enable i18n

Add localization and locale-aware routing using Wuchale. Ships with en and es locales, a LanguageSelect dropdown, server-side locale resolution in hooks.server.ts, and a reroute hook that maps localized URLs to their canonical paths. Locale catalogs are loaded per request with runWithLocale, and the public root layout gains a language switcher in the navbar. wuchale.config.js, vite.config.ts, and the $locales alias are all configured for you.

$ vela enable i18n
src/hooks.ts
import { deLocalizeDefault } from "wuchale/url";
import { matchUrl } from "$locales/main.url";
import { locales } from "$locales/data";

const rerouteDeLocalize = (url: string) => {
  const [upath, locale] = deLocalizeDefault(url, locales);
  const { path } = matchUrl(upath, locale);
  return path ?? url;
};

export const reroute = ({ url }) => rerouteDeLocalize(url.pathname);
src/lib/components/language-select.svelte
<script lang="ts">
  import * as Select from "$lib/components/ui/select/index.js";
  import { locales, type Locale } from "$locales/data.js";
  import { page } from "$app/state";
  import { goto } from "$app/navigation";
  import { deLocalizeDefault } from "wuchale/url";
  import { defaultLocale, translateUrl } from "$lib/url";

  let locale: Locale = $derived.by(() => {
    const [_, locale] = deLocalizeDefault(page.url.pathname, locales);
    return locale ?? defaultLocale;
  });

  function localeDisplayName(code: Locale): string {
    const names = new Intl.DisplayNames([code], { type: "language" });
    const raw = names.of(code);
    if (!raw) return code;
    return raw.charAt(0).toLocaleUpperCase(code) + raw.slice(1);
  }

  const localeNames: Record<string, string> = locales.reduce(
    (acc, loc) => {
      acc[loc] = localeDisplayName(loc);
      return acc;
    },
    {} as Record<string, string>,
  );

  const handleValueChange = (value: string) => {
    const translatedUrl = translateUrl(
      page.url.pathname,
      locale,
      value as Locale,
    );
    goto(translatedUrl, { invalidateAll: true });
  };

  let { class: className }: { class?: string } = $props();
</script>

<Select.Root type="single" value={locale} onValueChange={handleValueChange}>
  <Select.Trigger class={className}>{localeNames[locale]}</Select.Trigger>
  <Select.Content>
    {#each locales as locale}
      <Select.Item value={locale}>{localeNames[locale]}</Select.Item>
    {/each}
  </Select.Content>
</Select.Root>
src/lib/url.ts
import { deLocalizeDefault, fillParams } from "wuchale/url";
import type { Locale } from "../locales/data";
import { matchUrl } from "../locales/main.url";
import { locales } from "../locales/data";

export const defaultLocale = "en";

export function localize(path: string, locale: Locale) {
  if (locale === defaultLocale) {
    return path;
  }

  return `/${locale}${path}`;
}

export function translateUrl(
  url: string,
  fromLocale: Locale,
  toLocale: Locale,
) {
  const [pathOnly] = deLocalizeDefault(url, locales);
  const result = matchUrl(pathOnly, fromLocale);
  if (result.path !== null) {
    const targetPath = fillParams(result.params, result.altPatterns[toLocale]);
    return localize(targetPath, toLocale);
  }
  return localize(url, toLocale);
}
wuchale.config.js
// @ts-check
import { adapter as svelte } from "@wuchale/svelte";
import { adapter as js } from "wuchale/adapter-vanilla";
import { defineConfig } from "wuchale";

export default defineConfig({
  locales: ["en", "es"],
  adapters: {
    main: svelte({
      loader: "sveltekit",
      url: { localize: true, patterns: ["/"] },
    }),
    js: js({
      loader: "vite",
      files: [
        "src/**/+{page,layout}.{js,ts}",
        "src/**/+{page,layout}.server.{js,ts}",
      ],
    }),
  },
});