[ Interface ]
@sdxc/i18n
Language detection and MessageFormat 2 translators for Remix routers and remix/component
- Installs with
- @sdxc/logger@sdxc/messageformat
- Used by
- uptime, auth-saas, reader
- Source
- packages/i18n
Language detection and Unicode MessageFormat 2 translators for Remix routers and remix/component.
Installation
npm add @sdxc/i18n
Requires remix v3. Messages format through @sdxc/messageformat, and the middleware logs through @sdxc/logger.
The package has three entry points:
@sdxc/i18n: translators, the language detector andgetClientLocales; runs in the browser too.@sdxc/i18n/middleware: thei18nmiddleware forremix/router.@sdxc/i18n/middleware/cli: thei18nmiddleware for an@sdxc/clicommand line, which installs alongside.@sdxc/i18n/ui:IntlProvider,intl,setIntlandTransforremix/component.
Usage
Write a bundle per language
A bundle is a plain object nested by dotted key segment, with MessageFormat 2 strings as leaves.
export default {
greeting: "Hello {$name}",
inbox: {
unread:
".input {$count :number}\n.match $count\none {{{$count} message}}\n* {{{$count} messages}}",
article: "Read {#link}{$title}{/link}",
},
};
Variables are {$name} and interpolate unescaped. Plurals .match a :number input on the language's CLDR categories (one, few, other, …) or exact numbers, with * catching the rest. Markup is {#name}…{/name} or {#name/}, rendered by Trans.
Translate
import { createI18n } from "@sdxc/i18n";
import en from "./locales/en.js";
import es from "./locales/es.js";
let intl = createI18n({ locale: "es-MX", fallbackLanguage: "en", resources: { en, es } });
intl.t("greeting", { name: "Ada" }); // "Hola Ada", read from es
intl.t("inbox.unread", { count: 3 }); // from en when es lacks the key
intl.t("inbox.missing"); // "inbox.missing"
A key resolves through the locale, its primary subtag, the fallback language and its primary subtag, so es-MX reads es-MX, then es, then en. A key found nowhere returns itself.
Translate per request
import i18n from "@sdxc/i18n/middleware";
import { createRouter } from "remix/router";
import en from "./locales/en.js";
import es from "./locales/es.js";
let router = createRouter({
middleware: [
i18n({
detection: { supportedLanguages: ["en", "es"], fallbackLanguage: "en" },
resources: { en, es },
}),
],
});
router.get("/", (context) => {
return new Response(context.intl.t("greeting", { name: "Ada" }), {
headers: { "Content-Language": context.locale },
});
});
The middleware detects the language from ?lng=, a cookie, the session and Accept-Language, then publishes context.locale and context.intl.
Render with remix/component
import type { Handle } from "remix/component";
import { intl, IntlProvider, Trans } from "@sdxc/i18n/ui";
router.get("/", (context) =>
context.render(
<IntlProvider intl={context.intl}>
<Teaser title="Hello MF2" href="/posts/mf2" />
</IntlProvider>,
),
);
function Teaser(handle: Handle<{ title: string; href: string }>) {
return () => (
<article>
<h2>{intl(handle).t("greeting", { name: "Ada" })}</h2>
<Trans
i18nKey="inbox.article"
values={{ title: handle.props.title }}
components={{ link: <a href={handle.props.href} /> }}
/>
</article>
);
}
Translate without a request
import { createTranslator } from "@sdxc/i18n";
let translate = createTranslator({
resources: { en, es },
supportedLanguages: ["en", "es"],
fallbackLanguage: "en",
});
let { locale, t } = translate(user.language); // locale is "en" when user.language is unsupported
API
createI18n(options: I18nOptions): I18n
From @sdxc/i18n. A translator fixed to options.locale, built synchronously; messages compile on first use and are cached per resources object, so keep one bundle object per app.
options.locale: The language to translate intooptions.fallbackLanguage: The language whose bundle answers keys the locale lacks, and types the keysoptions.resources: Bundles keyed by languageoptions.onError:(error, key) => void, receiving syntax and formatting errors; errors are discarded without it
A placeholder that fails to format renders its fallback, such as {$name}, and a message that fails to compile renders its key.
createTranslator(options: TranslatorOptions): Translator
From @sdxc/i18n. A factory for code with no request behind it. Calling it with a language returns a cached Translation ({ locale, t, intl }); an unsupported or missing language binds to fallbackLanguage, so record translation.locale.
options.resources,options.fallbackLanguage,options.onError: As increateI18noptions.supportedLanguages: The languages a translation may bind to
LanguageDetector
From @sdxc/i18n. new LanguageDetector(options: LanguageDetectorOptions) creates a detector; detector.detect(request: Request, session?: Session): Promise<string> runs each method in order and returns the first supported match, or the fallback language. A candidate matches a supported language exactly first, then by primary subtag, so es-AR matches es. A method missing its option, such as cookie, is skipped, and a live Session passed to detect is read directly.
getClientLocales(requestOrHeaders: Request | Headers): string | undefined
From @sdxc/i18n. The highest-quality Accept-Language tag that Intl can represent, or undefined. It keeps the client's exact region (en-GB) for Intl formatting even when the app ships only en copy.
i18n(options: I18nMiddlewareOptions): Middleware
Default export of @sdxc/i18n/middleware. Sets context.locale and context.intl per request, and logs message errors as i18n.error warnings on the request's @sdxc/logger log. Importing it adds locale: string and intl: I18n to RequestContext from remix/router.
options.detection: ALanguageDetectorOptions; itsfallbackLanguagealso answers missing keysoptions.resources: Bundles keyed by language
Both middleware entries publish under the same context keys, CurrentLocale and CurrentIntl, exported from each, so code reading ctx.get(CurrentIntl) works in a route or a command.
i18n(options: TranslatorOptions): CommandMiddleware
Default export of @sdxc/i18n/middleware/cli, taking the same options as createTranslator. It installs three properties on an @sdxc/cli command's context:
ctx.translate: thecreateTranslatorfactory (keyTranslators), for text the command writes to someone else:ctx.translate(user.language)answers aTranslationwhoselocaleis the language it bound toctx.localeandctx.intl: the command's own output in the language of whoever runs it, from the first ofLC_ALL,LC_MESSAGESandLANGthat is set (es_AR.UTF-8matcheses-AR, thenes);C,POSIX, an unsupported language or no variable bindsfallbackLanguage
Message errors become i18n.error warnings on the run's @sdxc/logger log unless options.onError is given.
IntlProvider
From @sdxc/i18n/ui. Publishes its intl prop to every descendant and renders children; render it with a new I18n to switch language.
intl(handle: Handle): I18n
From @sdxc/i18n/ui. The I18n from the nearest IntlProvider, else the setIntl default; it throws when neither exists.
setIntl(intl: I18n): void
From @sdxc/i18n/ui. Registers the default I18n for intl in the browser; call it before mounting. It throws on the server, where a module-scoped translator would serve every request.
Trans
From @sdxc/i18n/ui. Renders a message with markup: {#name}…{/name} becomes components[name] with the enclosed content as children, and {#name/} the element alone.
i18nKey: The message key (keyis reserved byremix/component)values: The message's variablescomponents: Elements keyed by markup name; markup without an entry renders its content unwrapped and reports throughonErrorintl: The translator to use; defaults tointl(handle)
Types
I18n<R>
interface I18n<R = Messages> {
readonly locale: string;
t: Translate<R>; // (key, values?) => string
parts: TranslateParts<R>; // (key, values?) => MessagePart[]
onError: I18nErrorHandler; // (error: Error, key: string) => void
}
parts returns @sdxc/messageformat parts, markup included, for a renderer of your own; a missing key yields one text part holding the key.
Messages and MessageKey<R>
Messages is a bundle: { [key: string]: string | Messages }. MessageKey<R> is the union of dotted paths to R's string leaves, so with typed bundles t("inbox.unraed") is a type error. Keys are typed by resources[fallbackLanguage] when the fallback is a literal; bundles typed as Record<string, Messages> give string keys. IntlProvider, setIntl and Trans accept I18n<any>. Variables are untyped.
LanguageDetectorOptions and DetectionMethod
type DetectionMethod = "searchParams" | "cookie" | "session" | "header" | "custom";
interface LanguageDetectorOptions {
supportedLanguages: string[];
fallbackLanguage: string;
cookie?: Cookie; // holds the language as its plain value
sessionCookie?: Cookie; // with sessionStorage, loads the session outside middleware
sessionStorage?: SessionStorage;
sessionKey?: string; // default "lng"
searchParamKey?: string; // default "lng"
order?: DetectionMethod[]; // default searchParams, cookie, session, header
findLocale?(request: Request): Promise<string | string[] | null>; // the "custom" method
}
Setting findLocale puts custom first in the default order; returning null defers to the next method.
Translate, TranslateParts, I18nErrorHandler, I18nOptions, I18nMiddlewareOptions, TranslatorOptions, Translation, Translator
The function and option types used above. I18nMiddlewareOptions comes from @sdxc/i18n/middleware; the rest from @sdxc/i18n.
CurrentLocale, CurrentIntl and Translators
The context keys, from @sdxc/i18n/middleware (CurrentLocale, CurrentIntl) and @sdxc/i18n/middleware/cli (all three), for code that reads by key: ctx.get(CurrentIntl). A command line whose middleware includes i18n() types ctx.translate, ctx.locale and ctx.intl on every handler.
Pattern: Reading The Language From The Session
Order i18n after Remix's session middleware so the session method reads the live session, with no sessionCookie or sessionStorage needed.
import i18n from "@sdxc/i18n/middleware";
import { createCookie } from "remix/cookie";
import { session } from "remix/middleware/session";
import { createRouter } from "remix/router";
import { createCookieSessionStorage } from "remix/session-storage/cookie";
import en from "./locales/en.js";
import es from "./locales/es.js";
let sessionCookie = createCookie("__session", { secrets: ["s3cr3t"] });
let router = createRouter({
middleware: [
session(sessionCookie, createCookieSessionStorage()),
i18n({
detection: { supportedLanguages: ["en", "es"], fallbackLanguage: "en", sessionKey: "lng" },
resources: { en, es },
}),
],
});
Pattern: Letting The User Pick A Language
Store the choice in a cookie the detector reads. ?lng= still runs first, so a link overrides the stored choice for one request.
import i18n from "@sdxc/i18n/middleware";
import { createCookie } from "remix/cookie";
import { createRouter } from "remix/router";
import en from "./locales/en.js";
import es from "./locales/es.js";
let languageCookie = createCookie("lng", { maxAge: 60 * 60 * 24 * 365 });
let router = createRouter({
middleware: [
i18n({
detection: {
supportedLanguages: ["en", "es"],
fallbackLanguage: "en",
cookie: languageCookie,
},
resources: { en, es },
}),
],
});
router.post("/language", async (context) => {
let language = (await context.request.formData()).get("lng");
if (typeof language !== "string") return new Response(null, { status: 400 });
return new Response(null, {
status: 302,
headers: { Location: "/", "Set-Cookie": await languageCookie.serialize(language) },
});
});
Pattern: Locale In The URL Path
findLocale reads /es/dashboard; an unsupported segment falls through to the other methods.
import { LanguageDetector } from "@sdxc/i18n";
let detector = new LanguageDetector({
supportedLanguages: ["en", "es"],
fallbackLanguage: "en",
async findLocale(request) {
return new URL(request.url).pathname.split("/").at(1) ?? null;
},
});
let locale = await detector.detect(request);
Pattern: Translating In A Background Job
One translator serves every run; onError sends broken messages somewhere visible, and the email records the language it was actually written in.
import { createTranslator } from "@sdxc/i18n";
import { currentLog } from "@sdxc/logger";
import en from "./locales/en.js";
import es from "./locales/es.js";
let translate = createTranslator({
resources: { en, es },
supportedLanguages: ["en", "es"],
fallbackLanguage: "en",
onError: (error, key) => currentLog()?.warn("i18n.error", { key, message: error.message }),
});
export async function sendDigest(user: { email: string; language: string; unread: number }) {
let { locale, t } = translate(user.language);
await sendEmail({
to: user.email,
language: locale,
subject: t("inbox.unread", { count: user.unread }),
});
}
Pattern: Sending A Translated Email From A Command
An admin command writes its own output in the developer's language and the email in the recipient's, using the same bundles the routes use.
import { argument, command, commands, createCLI, failure, success } from "@sdxc/cli";
import i18n from "@sdxc/i18n/middleware/cli";
import * as s from "remix/data-schema";
import en from "./locales/en.js";
import es from "./locales/es.js";
let contract = commands({
invites: commands({ resend: command({ arguments: { email: argument(s.string()) } }) }),
});
let cli = createCLI({
name: "app",
commands: contract,
middleware: [
i18n({ resources: { en, es }, supportedLanguages: ["en", "es"], fallbackLanguage: "en" }),
],
});
cli.map(contract.invites.resend, async (ctx) => {
let user = await findUserByEmail(ctx.arguments.email);
if (user === null) return failure(new Error(ctx.intl.t("cli.userNotFound")));
let { locale, t } = ctx.translate(user.language);
await sendEmail({ to: user.email, language: locale, subject: t("invite.subject") });
ctx.stdout(`${ctx.intl.t("cli.inviteSent", { email: user.email })}\n`);
return success();
});
process.exitCode = await cli.run(process.argv.slice(2));
With LANG=es_AR.UTF-8 the command reports in Spanish, whatever language the email went out in.
Pattern: Hydrating Islands In The Browser
Register one translator before hydrating, so every island's intl(handle) and Trans resolve without a provider. Read the language from the server-rendered <html lang>.
import { createTranslator } from "@sdxc/i18n";
import { setIntl } from "@sdxc/i18n/ui";
import en from "./locales/en.js";
import es from "./locales/es.js";
let { intl } = createTranslator({
resources: { en, es },
supportedLanguages: ["en", "es"],
fallbackLanguage: "en",
})(document.documentElement.lang);
setIntl(intl);
Pattern: Formatting Dates In The Client's Region
Copy follows the detected language; dates and numbers follow the client's exact locale from getClientLocales.
import { getClientLocales } from "@sdxc/i18n";
router.get("/orders/:id", (context) => {
let region = getClientLocales(context.request) ?? context.locale; // "en-GB" for an en-only app
let placed = new Intl.DateTimeFormat(region, { dateStyle: "long" }).format(order.placedAt);
return new Response(context.intl.t("orders.placed", { date: placed }));
});