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
generateonce per locale, default first, and writes<html lang>per page. - content reads
<slug>/<locale>.mdand falls back to the default locale's file. - head writes one hreflang alternate per locale, an
x-default, andog:localewhen the map has the locale.
t(locale, key) never returns undefined: the requested locale, then the default locale, then
the key itself.
Example
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": "Головна" }
}
};- Priority order. Must not be empty.
- Must be one of
locales, orcreateAppthrows.
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 }))
});- One page per locale:
/,/en/and/uk/. - Translate in
load: the strings land in the JSON sidecar, so client navigation renders the same words.
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 |
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:
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 });
}
}
});- Keep the other params of the current page, such as
slug. replace: trueswaps the current history entry instead of adding one.
defaultLocalemust be inlocales. OtherwisecreateAppthrows[web] i18n.defaultLocale "en" is not in i18n.locales [uk].- A
{lang:?}route needsgeneratefor 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.isFallbackistruewhen the text is the default locale's. The hreflang links still list every locale. renderhas norequire. Read translations inloadand pass them throughctx.data, or import your table in the component.
Related
- i18n plugin: config and the
app.i18nAPI. - Routing: the
{lang:?}slot and specificity. - Content & collections: locale files and fallback.
- Head & SEO: hreflang and
og:locale.