Skip to content
Moku LabsDocs
@moku-labs/web pages

i18n

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

i18n.mdllms.txt

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

src/site.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.
src/routes.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.
src/app.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

LocaleURLAlso 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
build
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>

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:

src/islands/language-switch.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.