Digging Deeper
Localization
On this page
Introduction
@marmeon/i18n translates your app. Each module keeps its texts in one TypeScript file per language, and the English file is
the source: the compiler checks every key and every placeholder against it, without generated code. A request speaks the
language of its visitor, and a page renders from the same files on the server and in the browser.
const en = {
title: 'Notes',
count: '{count, plural, =0 {No notes yet} one {# note} other {# notes}}',
saved: 'Saved {title}.',
} as const;
export default en;
declare module '@marmeon/i18n/messages' {
interface Translations {
notes: typeof en;
}
}On the server, the injected Translator speaks the request's language. In a view, useTranslation() of @marmeon/react does:
this.#translator.get('notes.count', { count: 3 }); // "3 notes"const t = useTranslation('notes');
return <h1>{t('count', { count: notes.length })}</h1>;Every new app has the package, with English and German texts for the framework's own messages.
Language files
A module's texts live in modules/<module>/lang/<locale>.ts, and their namespace is the module's name. App-wide texts live in
lang/<locale>.ts, under the namespace app. A key is the namespace and the path: notes.count, app.sign_out. The namespace
marmeon belongs to the framework, and a module of that name fails the start.
A file default-exports strings, nested in objects as deep as you like. A key may not contain a dot. Every file is parsed when the app starts, and a message that is malformed fails the start with its key and file:
Message notes.saved in /srv/app/modules/notes/lang/en.ts is malformed: Unclosed placeholder {title at position 12 of "Saved {title"The English file is the source of the types. Declare it in Translations and keep as const: the texts as types are what
give the placeholders their types, so {count, plural, …} takes a number. Another language is checked against it:
import type { Messages } from '@marmeon/i18n/messages';
import type en from './en.ts';
export default {
title: 'Notizen',
count: '{count, plural, =0 {Noch keine Notizen} one {# Notiz} other {# Notizen}}',
saved: '{title} gespeichert.',
} satisfies Messages<typeof en>;satisfies Messages<typeof en> asks for every key of the English file and nothing more, and each text must keep the source's
placeholders. The app loads the files of every locale in APP_LOCALES, or of every locale it has files for when the variable is
not set. Files of other locales are skipped.
The framework's texts, under the namespace marmeon, come in English and German: the status pages, the CSRF and rate-limit
errors, and the message of every validation rule. Each package also keeps its English text in its code, for apps without this
package.
Messages
A message is text with placeholders, in a subset of ICU's message format. It is parsed once and formatted with Intl:
| Syntax | Takes |
|---|---|
{name} | A value. Numbers and dates are formatted for the locale. |
{n, number}, {n, number, integer}, {n, number, percent} | A number. |
{d, date}, {d, date, short}, … medium, long, full; {t, time, …} | A Date, a timestamp or an ISO string. |
{count, plural, =0 {none} one {# item} other {# items}} | A number. # is the number. |
{n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} | A number. |
{kind, select, link {…} other {…}} | A string. |
Branches nest, and a select inside a plural keeps #. plural, selectordinal and select need an other branch. A
literal brace is '{' and an apostrophe '', while a lone apostrophe, as in "it's", is just itself. offset:, number
skeletons and placeholder types other than those in the table are not supported. A value that is missing at runtime leaves its
placeholder visible, {name}, instead of failing.
On the server
The Translator is scoped to the request: two requests never share one, and each speaks its own visitor's language. Inject it
like any service:
import type { Authenticated } from '@marmeon/auth';
import { Controller, defineRequest, type ContextOf } from '@marmeon/http';
import { Translator } from '@marmeon/i18n';
import { rules as r } from '@marmeon/validation';
import { NoteRepository } from '../NoteRepository.ts';
export const StoreNoteRequest = defineRequest({
schema: r.object({ title: r.string().trim().required().max(200) }),
});
export class StoreNoteController extends Controller {
static request = StoreNoteRequest;
readonly #notes: NoteRepository;
readonly #translator: Translator;
constructor(notes: NoteRepository, translator: Translator) {
super();
this.#notes = notes;
this.#translator = translator;
}
async handle(ctx: ContextOf<typeof StoreNoteRequest> & Authenticated) {
await this.#notes.insert({ user_id: ctx.user.id, title: ctx.body.title, body: '' });
return this.back().flash('toast', { kind: 'success', message: this.#translator.get('notes.saved', { title: ctx.body.title }) });
}
}get(key, params)returns the message in the translator's locale, else in its language (de-ATfalls back tode), else inAPP_FALLBACK_LOCALE, formatted for the locale it was found in. When no locale has the key, it returns the key.- Keys are checked.
get('notes.titel')is a compile error that names the namespace and suggests keys nearby. A value for a placeholder that is missing, misspelt or of the wrong type is a compile error too. forLocale(locale)gives a translator fixed to another supported locale, for a text meant for someone else. An unknown locale keeps this translator's.has(key)says whether a key exists,localeis the translator's locale,localesthe supported ones with the default first, andformatformats numbers and dates, as formatting shows.
Outside a request, in a job or a command, each of which has a scope of its own, the Translator speaks APP_LOCALE. It cannot be
resolved from the root container, since it is scoped. Use Translators there.
Validation messages are translated into the request's language as well. A request's messages and attributes name your own
keys, and trans(key, params) gives a rule a checked key of its own. The validation page explains them.
Translators: any language, anywhere
Translators is a service of the whole app that hands out a translator for any locale. A job, a command, a scheduled task, a
queued listener or a notification injects it. Whoever a text is for, a mail or a notification, decides its language, never the
request that caused it:
import { EmailVerification } from '@marmeon/auth';
import { Translators } from '@marmeon/i18n';
import { Mailer } from '@marmeon/mail';
import { AppConfig } from '../../config/app.ts';
import { VerifyEmailMail } from './mail/VerifyEmailMail.ts';
import type { User } from './users.ts';
export class VerificationMailer {
readonly #verification: EmailVerification;
readonly #mailer: Mailer;
readonly #app: AppConfig;
readonly #translators: Translators;
constructor(verification: EmailVerification, mailer: Mailer, app: AppConfig, translators: Translators) {
this.#verification = verification;
this.#mailer = mailer;
this.#app = app;
this.#translators = translators;
}
async send(user: User): Promise<void> {
const mail = new VerifyEmailMail(user, this.#verification.link(user, { base: this.#app.url }), this.#translators.for(user.locale));
await this.#mailer.queue(mail, { queue: 'mail' });
}
}for(locale) takes the supported locale the value names, in the app's spelling, so DE is de. Anything else, null
included, gets APP_LOCALE: never a locale the app does not have. default is the translator of APP_LOCALE, and locales
the supported locales. The mail and notifications pages use it.
The request's locale
The SetLocale middleware of the web group picks the request's locale, after the session starts and before the CSRF check, so
its errors are translated too. With only one supported locale there is nothing to choose, and every request speaks it. With more,
it takes the first supported locale of:
- the session's
locale, a language switcher's choice; - the signed-in user's, through
LocalePreference; - the browser's
Accept-Language, by quality, then by order.de-ATfalls back tode, anddematches the first supportedde-…; APP_LOCALE.
The default LocalePreference knows no user. The starter kit binds one in its provider that reads the account's stored language:
container.scoped(LocalePreference, (c) => ({ preferredLocale: async () => (await c.make(Auth).user())?.locale }));A value from a session, a header or a form becomes a locale only when it names a supported one, and then in the app's spelling.
So what reaches <html lang>, the page and the browser's language files is always one of your locales, never a path or markup.
Every response of the web group carries Content-Language. With more than one supported locale it also carries
Vary: accept-language, so a cache keeps the languages apart. Routes of the api group pass no SetLocale: their Translator
speaks APP_LOCALE. The page carries its locale and the fallback locale, never the messages: the browser loads
those itself.
Locales is a service of the whole app: default, fallback, supported with the default first, explicit for whether
APP_LOCALES names them, and find(value), which returns the supported locale a value names, or undefined.
A language switcher
A switcher posts the chosen locale, checks it with Locales and writes it into the session:
import { ClientHistory, Controller, defineRequest, ValidationError, type ContextOf } from '@marmeon/http';
import { Locales } from '@marmeon/i18n';
import { Session } from '@marmeon/session';
import { rules as r } from '@marmeon/validation';
export const UpdateLocaleRequest = defineRequest({
schema: r.object({ locale: r.string().max(35) }),
});
export class UpdateLocaleController extends Controller {
static request = UpdateLocaleRequest;
readonly #locales: Locales;
readonly #session: Session;
readonly #history: ClientHistory;
constructor(locales: Locales, session: Session, history: ClientHistory) {
super();
this.#locales = locales;
this.#session = session;
this.#history = history;
}
handle(ctx: ContextOf<typeof UpdateLocaleRequest>) {
const locale = this.#locales.find(ctx.body.locale);
if (!locale) throw new ValidationError({ locale: ['Choose one of the offered languages.'] }, { locale: ctx.body.locale });
this.#session.put('locale', locale);
this.#history.clear({ siteData: ['cache'] });
return this.back('/');
}
}A message that is no translation key, like this one, stays as written. A key of your lang/ files is translated instead. The
session holds the choice for this browser. To make it the account's too, so that mails come in it and other devices start in
it, also write it into the locale column of the signed-in user's row, which the starter kit's users table has: a small method
of your UserRepository that updates the column, called when Auth has a user. The starter kit's LocalePreference then reads
it on every device.
this.#history.clear() makes the browser load the next page in full and drops the pages its cache keeps in the old language.
The locale is part of what the browser's other tabs watch, so they load again in the new language, as
tab sync explains.
In the browser
Pages render from the same language files in the browser and on the server. A new app loads them in bootstrap/i18n.ts and
hands them to its client and server entries:
import { createMessageLoader } from '@marmeon/i18n/messages';
import lang from 'virtual:marmeon/lang';
export const messages = createMessageLoader(lang);virtual:marmeon/lang comes from the marmeon() Vite plugin: every language file as a chunk of its own, loaded when a page
needs its locale. A page loads, for each namespace, its file in the page's locale, else in that locale's language, else in the
fallback locale. A locale is only ever a key into these files, checked to be a language tag first.
The browser gets the app's namespaces only, never the framework's marmeon texts: status pages, server errors and validation
messages arrive translated. useTranslation(namespace) returns t(key, params), typed like the server's, with t.locale,
t.format and t.has(key). The pages page shows the hooks in views.
Formatting
translator.format, t.format in a view and useLocale().format format numbers and dates for their locale:
format.number(1234.5); // "1,234.5", and "1.234,5" in German
format.date(note.created_at, { dateStyle: 'long' }); // a Date, a timestamp or an ISO string; medium by default
format.relativeTime(-3, 'day'); // "3 days ago"
format.relative(note.updated_at, now); // "3 minutes ago", "yesterday", "in 2 days"relative() picks the largest unit that fits. Pass it the server's now as a prop rather than the browser's clock, so the server
and the browser render the same text.
Configuration
| Variable | Default | Effect |
|---|---|---|
APP_LOCALE | en | The default locale, for visitors whose session, account and browser name none of the supported ones. |
APP_FALLBACK_LOCALE | en | Where a message missing in a locale comes from: the language of the source files. |
APP_LOCALES | every locale with language files | The supported locales, comma-separated: en,de. |
Each value must be a language tag, such as en or de-AT. With APP_LOCALES set, APP_LOCALE and APP_FALLBACK_LOCALE must be
among them, or the app does not start. Without it, the supported locales are those the app has files for, plus the two others.
Testing
A test speaks to the app like a browser, with its languages or a session:
import { Translators } from '@marmeon/i18n';
import { createTestApp } from '@marmeon/testing';
import { expect, it } from 'vitest';
import application from '../../bootstrap/app.ts';
it('answers in the browser’s language', async () => {
const app = await createTestApp(application);
const login = await app.withHeaders({ 'accept-language': 'de-DE,de;q=0.9' }).navigating().get('/login');
expect(login.page()).toMatchObject({ locale: 'de' });
await app.withSession({ locale: 'de' }).get('/').assertOk();
expect(app.make(Translators).for('de').get('notes.title')).toBe('Notizen');
});The type checks, a key missing in a translation or a placeholder dropped, are best kept in a *.type-test.ts file with
@ts-expect-error: the type check compiles it, and nothing runs it.