Translations
An app in more than one language needs four things: the words of each language kept in one
place, the current language reachable from any component, plural forms and dates written the way
that language writes them, and <html lang> saying which language the page is in. Ramonda has no
translation package; each of the four is a few lines of ordinary code, and this page is those lines.
The words: a dictionary per language
One object per language, each of the same shape. The type is what keeps them in step: a key added to one language and forgotten in another is a type error, found before anyone switches language.
type Locale = "en" | "sr-Latn";
/** A word that changes with the count: `other` always, and whichever forms the language has. */
type Plural = { other: string } & Partial<Record<Intl.LDMLPluralRule, string>>;
interface Messages {
title: string;
switchLanguage: string;
matches: Plural;
}
const messages: Record<Locale, Messages> = {
en: {
title: "Matches",
switchLanguage: "Srpski",
matches: { one: "{n} match", other: "{n} matches" },
},
"sr-Latn": {
title: "Utakmice",
switchLanguage: "English",
matches: { one: "{n} utakmica", few: "{n} utakmice", other: "{n} utakmica" },
},
};
const rules = new Map<Locale, Intl.PluralRules>();
/** The form `n` takes in this language, with `n` written in. */
function plural(locale: Locale, forms: Plural, n: number): string {
let rule = rules.get(locale);
if (rule === undefined) {
rule = new Intl.PluralRules(locale);
rules.set(locale, rule);
}
return (forms[rule.select(n)] ?? forms.other).replace("{n}", String(n));
}
The key is a language tag, and it names the script when a language has two. Serbian is written
in both: sr is Cyrillic and sr-Latn Latin, and Intl below writes a month's name in the script the
tag says — so a dictionary in Latin goes with sr-Latn, and so does <html lang>.
Plurals are not "one or many". English has two forms; Serbian has three — 1 utakmica,
2 utakmice, 5 utakmica — and other languages have up to six. Intl.PluralRules knows which form
a number takes in each language, so the dictionary names the forms and the code never counts.
The language: a context
The current language is needed all over the app and changes from one place, which is what a
context is for. The component at the top holds it in @state and provides
it; Head puts it on <html lang>, which is what a
screen reader picks its pronunciation from:
import { Component, Head, createContext, state } from "@ramonda/core";
const [LanguageProvider, LanguageConsumer] = createContext({ locale: "en" as Locale });
class App extends Component {
@state locale: Locale = "en";
language = this.use(LanguageProvider, (self: App) => ({ locale: self.locale }));
head = this.use(Head, (self: App) => ({ lang: self.locale, title: messages[self.locale].title }));
toggle() {
this.locale = this.locale === "en" ? "sr-Latn" : "en";
}
render() {
return (
<main>
<button onclick={this.toggle}>{messages[this.locale].switchLanguage}</button>
<MatchCount count={3} />
</main>
);
}
}
A component below reads the language from the context and looks its words up:
class MatchCount extends Component<{ count: number }> {
language = this.use(LanguageConsumer);
render() {
const { locale } = this.language;
return <p>{plural(locale, messages[locale].matches, this.props.count)}</p>;
}
}
Reading this.language.locale is what makes the switch reach it: a component that reads a key of a
context re-renders when that key changes, and one that never reads it is left alone.
Numbers and dates: Intl, with the language named
Intl.NumberFormat and Intl.DateTimeFormat write a number or a date the way a language does —
1,234.5 in English, 1.234,5 in Serbian. Name the language and, for a date, the time zone:
function price(locale: Locale, euros: number): string {
return new Intl.NumberFormat(locale, { style: "currency", currency: "EUR" }).format(euros);
}
function day(locale: Locale, date: Date): string {
return new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone: "Europe/Belgrade" }).format(date);
}
Left out, both come from the machine the code runs on. On a server-rendered page that is two machines — the server's settings and the reader's — and the page the server sent is not the page the browser draws. That is a hydration mismatch, and naming both is how it is avoided.
Remembering the choice
@state lasts as long as the page. To keep the reader's choice past a reload, keep it where the next
visit finds it before anything renders — in the address, or in a cookie.
A choice kept in the browser is not enough on a page baked at build time. Every reader gets the same file, so a reader who chose English is sent the page in Serbian, and the switch after hydration is a flash on every load. The answer is to make each language its own page.
One route per language
The language is the first segment of the address — /en/matches, /sr-Latn/matches — and the
component above the outlet reads it from the path:
import { Component, Head, compute, createContext } from "@ramonda/core";
import { createRoutes, createRouter } from "@ramonda/router";
const routes = createRoutes({ "/:lang/matches": <MatchCount count={3} /> });
const { Router, RouteOutlet, Navigator } = createRouter(routes);
const [LanguageProvider] = createContext({ locale: "en" as Locale });
class App extends Component {
router = this.use(Router);
route = this.use(Navigator);
language = this.use(LanguageProvider, (self: App) => ({ locale: self.locale }));
head = this.use(Head, (self: App) => ({ lang: self.locale, link: self.alternates }));
/** The first segment, when it is a language this app has. */
@compute get locale(): Locale {
return this.route.pathname.startsWith("/sr-Latn/") ? "sr-Latn" : "en";
}
/** The same page in every language, for a search engine: `<link rel="alternate" hreflang>`. */
@compute get alternates() {
const rest = this.route.pathname.replace(/^\/[^/]+/, "");
return (["en", "sr-Latn"] as const).map((lang) => ({ rel: "alternate", hreflang: lang, href: `/${lang}${rest}` }));
}
render() {
return <RouteOutlet routes={routes} />;
}
}
Each language is then a page of its own: baked at build in that language, linked to, and found by a
search engine in it. Mark the route prerender: true in src/entry-server.tsx and list the pages in
PRERENDER_PATHS — ["/en/matches", "/sr-Latn/matches"] — and switching language is a link to the same
page under the other segment. A host serving only files cannot read a reader's settings, so / itself is
the default language's page, or a link to each.
With a server, a cookie works too, read through requestContext(): the first HTML is
already in the reader's language. A page that reads the request is rendered for each request, though,
and cannot be baked.
Words inside the page that are in another language
A quotation, a name, a word left untranslated: give it its own lang, so it is read in that language
rather than the page's.
<p>
The motto is <q lang="la">citius, altius, fortius</q>.
</p>
Form messages
A field's error messages come from its validators, and they carry a key for the failure as well as the text. Translating the messages maps that key through the dictionary.
Next
- Context — what a consumer re-renders for, and the order
use()calls take. Head— the title, the description andlang, from the component that knows them.- Hydration mismatches — the other values that differ between a server and a browser.