sdxc

[ Interface ]

@sdxc/i18n

Language detection and MessageFormat 2 translators for Remix routers and remix/component

npm add @sdxc/i18n remix
pnpm add @sdxc/i18n remix
yarn add @sdxc/i18n remix
bun add @sdxc/i18n remix
Used by
uptime, auth-saas, reader

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 and getClientLocales; runs in the browser too.

  • @sdxc/i18n/middleware: the i18n middleware for remix/router.

  • @sdxc/i18n/middleware/cli: the i18n middleware for an @sdxc/cli command line, which installs alongside.

  • @sdxc/i18n/ui: IntlProvider, intl, setIntl and Trans for remix/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 into

  • options.fallbackLanguage: The language whose bundle answers keys the locale lacks, and types the keys

  • options.resources: Bundles keyed by language

  • options.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 in createI18n

  • options.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: A LanguageDetectorOptions; its fallbackLanguage also answers missing keys

  • options.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: the createTranslator factory (key Translators), for text the command writes to someone else: ctx.translate(user.language) answers a Translation whose locale is the language it bound to

  • ctx.locale and ctx.intl: the command's own output in the language of whoever runs it, from the first of LC_ALL, LC_MESSAGES and LANG that is set (es_AR.UTF-8 matches es-AR, then es); C, POSIX, an unsupported language or no variable binds fallbackLanguage

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 (key is reserved by remix/component)

  • values: The message's variables

  • components: Elements keyed by markup name; markup without an entry renders its content unwrapped and reports through onError

  • intl: The translator to use; defaults to intl(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 }));
});

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.