# i18n

> Locales, translations, and the default locale at bare paths.

## How i18n works

The `i18n` plugin is plain data: the locale list, the default locale, display names, an
`og:locale` map and a flat translation table. It has no state and no events. The other plugins
read it:

- **router** builds the `{lang:?}` matcher from the locale list. The default locale is served at
  bare paths, every other locale under its prefix.
- **build** calls `generate` once per locale, default first, and writes `<html lang>` per page.
- **content** reads `<slug>/<locale>.md` and falls back to the default locale's file.
- **head** writes one hreflang alternate per locale, an `x-default`, and `og:locale` when the map
  has the locale.

`t(locale, key)` never returns `undefined`: the requested locale, then the default locale, then
the key itself.

## Example

```ts
export const i18n = {
  locales: ["en", "uk"],
  defaultLocale: "en",
  localeNames: { en: "English", uk: "Українська" },
  ogLocaleMap: { en: "en_US", uk: "uk_UA" },
  translations: {
    en: { "nav.home": "Home", "nav.about": "About" },
    uk: { "nav.home": "Головна" }
  }
};
```

1. Priority order. Must not be empty.
2. Must be one of `locales`, or `createApp` throws.

```tsx
import { defineRoutes, i18nPlugin, route } from "@moku-labs/web/browser";

export const routes = defineRoutes({
  home: route("/{lang:?}/")
    .generate(ctx => [{ lang: ctx.locale }])
    .load(ctx => {
      const i18n = ctx.require(i18nPlugin);
      return { home: i18n.t(ctx.locale, "nav.home"), about: i18n.t(ctx.locale, "nav.about") };
    })
    .render(ctx => (
      <nav>
        <a href={ctx.url("home", { lang: ctx.locale })}>{ctx.data.home}</a>
        <span>{ctx.data.about}</span>
      </nav>
    ))
    .head(ctx => ({ title: ctx.data.home }))
});
```

1. One page per locale: `/`, `/en/` and `/uk/`.
2. Translate in `load`: the strings land in the JSON sidecar, so client navigation renders the same words.

```ts
import { buildPlugin, createApp } from "@moku-labs/web";
import { routes } from "./routes";
import { i18n } from "./site";

export const app = createApp({
  plugins: [buildPlugin],
  pluginConfigs: {
    site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." },
    i18n,
    router: { routes }
  }
});
```

On `/uk/` the page reads `Головна` and `About`: the Ukrainian table has no `nav.about`, so it falls
back to English.

## URLs

| Locale | URL | Also written |
|---|---|---|
| `en` (default) | `/hello-world/` | `/en/hello-world/`, a copy with a canonical link to the bare URL |
| `uk` | `/uk/hello-world/` | none |
| `uk`, no `uk.md` | `/uk/second-post/` | none. The page shows the English text with `isFallback: true` |

```sh
$ bun run build
▟▙ moku web  build                                       v2.4.11
✓ content · 1239ms
✓ pages · 5ms
✓ BUILD · 9 pages                                1252ms · ./dist/
$ ls dist/uk dist/en
dist/en:
hello-world  index.html  second-post

dist/uk:
hello-world  index.html  second-post
$ head -c 40 dist/uk/hello-world/index.html
<!DOCTYPE html><html lang="uk"><head>
```

## Links between locales

`ctx.url` and `app.router.toUrl` know the default locale. `createUrls` is app-free, so pass it
the default locale yourself. A language switcher replaces the history entry, so Back does not
step through the same page in another language:

```ts
import { createIsland } from "@moku-labs/web/browser";

export const languageSwitch = createIsland("language-switch", {
  events: {
    "click [data-locale]": (ctx, _event, target) => {
      const locale = target.getAttribute("data-locale") ?? "en";
      const name = target.getAttribute("data-route") ?? "home";
      const params: Record<string, string> = { lang: locale };
      for (const [key, value] of Object.entries(ctx.params)) {
        if (key !== "lang" && value !== undefined) params[key] = value;
      }
      ctx.navigate(ctx.url(name, params), { replace: true });
    }
  }
});
```

1. Keep the other params of the current page, such as `slug`.
2. `replace: true` swaps the current history entry instead of adding one.

- **`defaultLocale` must be in `locales`.** Otherwise `createApp` throws `[web] i18n.defaultLocale "en" is not in i18n.locales [uk].`
- **A `{lang:?}` route needs `generate` for every locale.** Without it the build writes the default locale only.
- **The default locale has no prefix.** `/hello/` is English and `/uk/hello/` is Ukrainian. The `/en/hello/` copy exists so explicit links work, and the sitemap leaves it out.
- **Show a notice on fallback pages.** `article.isFallback` is `true` when the text is the default locale's. The hreflang links still list every locale.
- **`render` has no `require`.** Read translations in `load` and pass them through `ctx.data`, or import your table in the component.

## Related

- [i18n plugin](/docs/web/plugins/i18n/): config and the `app.i18n` API.
- [Routing](/docs/web/guides/routing/): the `{lang:?}` slot and specificity.
- [Content & collections](/docs/web/guides/content/): locale files and fallback.
- [Head & SEO](/docs/web/guides/head/): hreflang and `og:locale`.


Source: https://moku-labs.com/docs/web/guides/i18n/ · @moku-labs/web@2.4.11
