i18n
Translations for a Void app’s pages and server messages, on Intl. Messages
live in code; the locale is the user’s choice in a cookie, else the
browser’s Accept-Language, else the messages’ own language. Numbers,
money, dates, relative times and lists come from Intl in that locale. The
module imports nothing, so the Worker and the browser share it.
examples/crm uses it.
Set up
Section titled “Set up”The messages, in a file of your own that the server and the pages both import:
import { defineI18n } from "@recranet/flare/i18n"
export const i18n = defineI18n({ locale: "en", messages: { "overview.welcome": "Welcome back, {name}.", "overview.openTasks": "{count, plural, one {# open task} other {# open tasks}}", }, translations: { nl: { "overview.welcome": "Welkom terug, {name}.", "overview.openTasks": "{count, plural, one {# open taak} other {# open taken}}" }, },})defineI18n |
|
|---|---|
locale |
The language messages are in: the default, and the fallback for a key a translation lacks |
messages |
Messages by key. Their keys are what t() takes, typed |
translations |
Other locales, each with some or all of the keys |
cookie |
The override cookie’s name. Default locale |
The locale per request, in the middleware that sets the pages’ shared data,
with <html lang> to match:
const locale = i18n.locale(c)c.set("shared", { ...shared, locale })c.set("headDefaults", { htmlAttrs: { lang: locale } })Leave htmlAttrs.lang out of void.config.ts’s head, or it wins.
Messages
Section titled “Messages”Plain strings with ICU-style arguments:
{name} |
The value; a number is formatted for the locale (1.200 in Dutch), a Date as a medium date |
{count, plural, =0 {none} one {# item} other {# items}} |
=n first, then Intl.PluralRules’ category, then other; # is the count, formatted |
{role, select, owner {Owner} other {Member}} |
The branch named by the value, else other |
Branches can hold arguments of their own. A key missing from a locale falls
back to the source message, then to the key itself. i18n.missing() lists
each translation’s missing keys, for a test that keeps them complete:
expect(i18n.missing()).toEqual({ nl: [] })In a page
Section titled “In a page”A composable over the shared locale and time zone:
import { useShared } from "@void/vue"import { formatter, type Params } from "@recranet/flare/i18n"import { computed } from "vue"import { i18n, type MessageKey } from "./messages.ts"
export const useI18n = () => { const shared = useShared() const t = computed(() => i18n.translator(shared.locale)) const f = computed(() => formatter(shared.locale, { timeZone: shared.timeZone })) return { t: (key: MessageKey, params?: Params) => t.value(key, params), f }}<h1>{{ t("overview.title") }}</h1><p>{{ t("overview.welcome", { name: shared.user?.name }) }}</p><span>{{ f.money(deal.amount) }} · {{ f.relative(activity.occurredAt, now) }}</span>The server renders in the request’s locale and the browser hydrates with the same shared value, so they agree.
formatter(locale, { timeZone }) gives number, money (cents),
date, dateTime, relative (“yesterday”, “in 2 hours”) and list. Pass
the browser’s time zone for moments, or the server renders UTC.
Server messages
Section titled “Server messages”i18n.t(c, key, params) translates in the request’s locale: an error body,
a validation message. Any context with req.header() fits; without
headers, the source locale.
throw new HTTPException(403, { res: Response.json({ error: i18n.t(c, "error.apiKeyLimit") }, { status: 403 }) })An API client gets its Accept-Language. Mail and jobs have no request:
i18n.translator(locale) with a locale you stored.
The override
Section titled “The override”i18n.cookie(locale) is the cookie that sets it, i18n.cookie(null) the
one that clears it (back to the browser’s languages), for document.cookie
or a Set-Cookie header. i18n.override(document.cookie) reads the current
choice, null for none. After setting it, refresh the page’s data
(router.refresh()): the loaders and shared data run again in the new
language, and <html lang> follows shared.locale on the client.
The cookie names a locale; one the app doesn’t have is ignored. Tags match
by language, so nl-BE finds nl.
negotiate(header, locales) is the Accept-Language match on its own: the
header’s order of preference (q), then the exact tag, its language, or
another region of it.
In the CRM
Section titled “In the CRM”src/messages.ts: English and Dutch for the shell (sidebar, breadcrumb, top bar, user menu), sign-in, the overview and the API key permission error. The other pages are English for now.- The user menu’s Language section: English, Nederlands, or the browser’s language.
lib/access.tsanswers a key’s refused action in the caller’s language.test/i18n.test.ts: the translations are complete, pages followAccept-Languageand the cookie, the overview’s money is Dutch, and the API error is too.
- Why not vue-i18n, i18next or FormatJS: decision 13.
- No locale in the URL: the pages are behind a sign-in, so there is nothing for a search engine to index per language.
- The choice is per browser (a cookie), not stored on the user.
- Every locale’s messages go into the browser bundle. Split them per locale when they grow.
- The ICU subset is arguments,
pluralandselect. Noselectordinal, number skeletons or date arguments with styles; format those withformatterand pass the string.