Internationalisation

One JSON file per locale, t() to read it, and a switch that flips the whole site — direction included.

A site speaks several languages by keeping its text in one JSON file per locale and reading it with t("key"). Switching the locale re-renders every translated string, flips the document to right-to-left where the language reads that way, and changes how format and ago spell numbers and dates.

Setting up

In webfluent.app.json:

json

{ "i18n": { "default_locale": "en", "locales": ["en", "ar", "es"], "dir": "src/translations" } }

One file per locale in that directory, flat keys with dots by convention:

json

{ "nav.home": "Home", "nav.about": "About", "welcome": "Welcome, {name}!", "items.one": "{count} item", "items.other": "{count} items", "actions.save": "Save" }

json

{ "nav.home": "الرئيسية", "nav.about": "حول", "welcome": "أهلاً، {name}!", "items.zero": "لا عناصر", "items.one": "عنصر واحد", "items.two": "عنصران", "items.few": "{count} عناصر", "items.many": "{count} عنصراً", "items.other": "{count} عنصر", "actions.save": "حفظ" }

A missing file is a build warning; a key missing from a locale falls back to the default locale, then to the key itself.

t("key")

wf

type User { name: String } page Home(path: "/", title: "Home", description: "The home page.") { state user = User(name: "Sam") state items: [String] = ["a", "b", "c"] Heading(t("welcome", { name: user.name })).h1 Text(t("items", { count: items.length })) Button(t("actions.save")).primary }
  • t("key") is the string for the current locale, reactive: every call re-evaluates when the locale changes.
  • t("key", { name: value }) fills {name} placeholders from the map.
  • A count picks the plural form: key.one when the locale's rules say "one", key.other otherwise, and key.zero, key.two, key.few, key.many when the file has them and the locale calls for them (Arabic has all six; English two). Falls back to key.other, then key.
  • The static paint resolves t for the default locale at build time, so the pre-rendered HTML is already in the default language.

Switching the locale

wf

app { state chosen = "en" Navbar { Navbar.Brand { Link("i18n", to: "/") } Navbar.Links { Link(t("nav.home"), to: "/") Link(t("nav.about"), to: "/about") } Navbar.Actions { Select(bind: chosen, label: t("nav.language")) { on change { setLocale(chosen) } Select.Option("English", value: "en") Select.Option("العربية", value: "ar") Select.Option("Español", value: "es") } } } Router } page Home(path: "/") { Text("Locale: {locale}, direction: {dir}") if locale == "ar" { Text("النسخة العربية") } }

setLocale("ar") switches; locale is the current code and dir is "ltr" or "rtl". On a switch, <html lang> and <html dir> are set: ar, he, fa and ur are right-to-left, everything else left-to-right. The built-ins use logical CSS properties (margin-inline-start, not margin-left), so layouts mirror without work on your side; do the same in your own style blocks.

An app block may declare state like a page, as chosen above.

Which locale a page opens in

A page opens in default_locale, unless its address carries ?lang=<code> for one of the configured locales — /about?lang=ar opens the Arabic page. That is the address the build gives each language in the hreflang alternates it writes for search engines, so a reader who arrives from a search result in Arabic reads Arabic.

The runtime does not guess from the browser's language, and it does not remember a choice across visits on its own: both are a few lines of yours. To remember what the reader picked, keep it in a store with persist, and apply it from an effect in the app:

wf

store Prefs { persist language = "" action choose(code: String) { language = code setLocale(code) } } app { effect { if Prefs.language != "" { setLocale(Prefs.language) } } Navbar { Navbar.Brand { Link("i18n", to: "/") } Navbar.Actions { Button("English") { on click { Prefs.choose("en") } } Button("العربية") { on click { Prefs.choose("ar") } } } } Router }

The stored value starts empty — "no choice yet" — so a first visit, and a search engine's crawler, still get the ?lang= locale or the default. To start from the browser's language, apply it when nothing is stored: setLocale(navigator.language.slice(0, 2)).

A static build pre-renders every page once, in the default locale; another locale is applied as the page loads. For search engines that is what the ?lang= alternates are for.

Numbers, dates and the locale

format and ago follow the current locale (chapter 14): format(1234.5, .currency, "EUR") is €1,234.50 in en, 1.234,50 € in de; ago(date) says "il y a 3 jours" in fr. The static paint uses a table of the common locales' separators and currency symbols; the live page uses Intl and corrects any difference.

Translating a Markdown page or a .md page

Text inside a .md page is not passed through t; keep one Markdown file per locale and route by a :locale parameter, or keep the long-form text in the translation file under a key and render Markdown(t("about.body")).

Checklist

  • Every visible string goes through t, including label:, placeholder: and aria-label: values.
  • Never build a sentence from parts (t("x") + name); use a placeholder so a translator can reorder it.
  • Give every count a plural key pair, even in English.
  • Test with an RTL locale once: alignment, icons that imply direction (arrows), and any left/right in your own CSS.