[ Interface ]
@sdxc/i18n
Language detection and MessageFormat 2 translators for Remix routers and remix/ui
- Installs with
- @sdxc/logger@sdxc/messageformat
- Depends on
remix- Used by
- uptime, auth-saas, reader
- Source
- packages/i18n
Language detection, translators for Unicode MessageFormat 2 messages, a Remix middleware that publishes one per request, and remix/ui components that render messages containing markup.
Messages format through @sdxc/messageformat.
Installation
npm add @sdxc/i18n
Requires remix (v3) as a companion; the remix/ui exports are only needed when rendering through remix/ui.
The package ships three entry points:
@sdxc/i18n—createI18n,createTranslator,LanguageDetector,getClientLocales, and theI18n/Translate/Messagestypes. No router or logger dependency, so it runs in the browser.@sdxc/i18n/middleware— the default-exportedi18nmiddleware forremix/router.@sdxc/i18n/ui—IntlProvider,intl,setIntl, andTransforremix/ui. Browser-safe.
Messages
Bundles are plain objects, one per language, nested by dotted key segment, with MessageFormat 2 strings as leaves:
export default {
greeting: "Hello {$name}",
feeds: {
unread:
".input {$count :number}\n.match $count\none {{{$count} unread}}\n* {{{$count} unread}}",
article: "Read {#articleLink}{$title}{/articleLink}",
},
};
Variables are
{$name}. Values interpolate raw, with no escaping: JSX escapes text nodes.Plurals are one key with a
.matchon a:numberinput; the variant is picked by the language's CLDR plural category (one,few,many,other, …) or an exact number, and*catches the rest.Markup is
{#name}…{/name}or standalone{#name/}, rendered byTrans. Any name works,linkandbrincluded.
Usage
Translating
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"
intl.t("feeds.unread", { count: 1200 }); // the English copy when neither es-MX nor es has the key
intl.t("feeds.missing"); // "feeds.missing"
Per-request translation in a router
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) => {
// context.locale is the detected language, e.g. "es"
return new Response(context.intl.t("greeting", { name: "Ada" }));
});
Standalone detection
import { LanguageDetector } from "@sdxc/i18n";
let detector = new LanguageDetector({
supportedLanguages: ["en", "es"],
fallbackLanguage: "en",
});
let locale = await detector.detect(request); // always a supported language
Translating without a request
import { createTranslator } from "@sdxc/i18n";
let translate = createTranslator({
resources: { en, es },
supportedLanguages: ["en", "es"],
fallbackLanguage: "en",
});
// In a job, a consumer, a browser bootstrap, or anywhere `context.intl` does not exist:
let { locale, t } = translate(user.language);
Rendering with remix/ui
import { intl, IntlProvider } from "@sdxc/i18n/ui";
router.get("/", (context) =>
context.render(
<IntlProvider intl={context.intl}>
<Greeting />
</IntlProvider>,
),
);
function Greeting(handle: Handle) {
return () => <p>{intl(handle).t("greeting", { name: "Ada" })}</p>;
}
Messages with markup
import { Trans } from "@sdxc/i18n/ui";
// feeds.article: "Read {#articleLink}{$title}{/articleLink}"
<Trans
i18nKey="feeds.article"
values={{ title: item.title }}
components={{ articleLink: <a href={item.link} /> }}
/>;
Hydrated islands
import { createTranslator } from "@sdxc/i18n";
import { setIntl } from "@sdxc/i18n/ui";
let { intl } = createTranslator({ resources, supportedLanguages, fallbackLanguage })(
document.documentElement.lang,
);
setIntl(intl);
API
createI18n(options: I18nOptions<Resources, Fallback>): I18n<Resources[Fallback]>
From @sdxc/i18n. Creates a translator fixed to options.locale. It is synchronous and does no work up front.
options.locale: The language to translate intooptions.fallbackLanguage: The language whose bundle answers keys the locale lacksoptions.resources: Bundles keyed by languageoptions.onError:(error: Error, key: string) => void, receiving syntax and formatting errors; without it errors are discarded
A key resolves through [locale, primary(locale), fallbackLanguage, primary(fallbackLanguage)], deduplicated and limited to languages in resources, so an en-US request reads an en-US bundle first and then en. The first string leaf at the dotted key wins, and a key with no message anywhere returns the key itself.
Each message compiles on first use for the language it was found in, and is cached per resources object, language and key, so concurrent requests share compiled messages and two apps with different bundles never collide. Keep one resources object per app to benefit from the cache.
A placeholder that fails to format shows as its MessageFormat 2 fallback, such as {$name}; a message that fails to compile renders its key. Both reach onError and neither throws.
I18n<R>
interface I18n<R = Messages> {
readonly locale: string;
t: Translate<R>; // (key, values?) => string
parts: TranslateParts<R>; // (key, values?) => MessagePart[]
onError: I18nErrorHandler;
}
An I18n is immutable. parts returns @sdxc/messageformat parts, markup included, for a renderer of its own; a missing key yields a single text part holding the key. onError is where Trans reports markup it cannot match.
i18n(options: I18nMiddlewareOptions): Middleware
Default export of @sdxc/i18n/middleware. Detects the request language and sets context.locale and context.intl, an I18n for that language. Message errors are logged as i18n.error warnings on the invocation's @sdxc/logger log.
options.detection: SeeLanguageDetectorOptions;fallbackLanguagealso answers missing keysoptions.resources: Bundles keyed by language
Importing the module augments RequestContext from remix/router with locale: string and intl: I18n.
LanguageDetector
From @sdxc/i18n. Detects the user's preferred language server-side from a Request, validating every candidate against the supported languages: an exact subtag match first, then loosely by primary language code, so es-AR matches a supported es. The fallback language is returned when nothing matches.
new LanguageDetector(options: LanguageDetectorOptions)
Creates a detector. A method missing its required option — cookie detection without a cookie, say — is skipped rather than treated as an error, so detection always resolves to a supported language.
detector.detect(request: Request, session?: Session): Promise<string>
Probes each configured method in order and returns the first supported match, or the fallback language. Passing a live Session makes the session method read it directly instead of loading from storage.
getClientLocales(requestOrHeaders: Request | Headers): string | undefined
From @sdxc/i18n. Returns the client's best-quality locale from the Accept-Language header, filtered to tags the JavaScript Intl APIs can represent, or undefined when the header is missing or unusable. This is the right input for Intl formatters: it honors the client's exact regional preference (en-GB dates) even for an app that only ships en translations.
import { getClientLocales } from "@sdxc/i18n";
let date = new Date().toLocaleDateString(getClientLocales(request));
createTranslator(options: TranslatorOptions): Translator
From @sdxc/i18n. Creates a translator over a fixed set of bundles, for code with no request behind it. The returned Translator takes a language and returns a Translation synchronously: the language it bound to, its t, and the I18n behind it.
A language outside supportedLanguages resolves to fallbackLanguage first, so record translation.locale rather than the language you asked for: they differ exactly when the app does not ship the requested one. Translations are cached per resolved language and per translator.
options.resources: Every language's bundleoptions.supportedLanguages: The languages the caller shipsoptions.fallbackLanguage: The default language, and the one missing keys resolve throughoptions.onError: Receives message errors; pass one that logs them, since the entry point stays free of a logger so it can run in the browser
IntlProvider
remix/ui context provider, from @sdxc/i18n/ui. Publishes the intl prop to every descendant through context and renders children unchanged. To switch language on the client, render it with a new I18n.
setIntl(intl: I18n): void
From @sdxc/i18n/ui. Registers a module-scoped default I18n for intl to fall back to when there is no ancestor IntlProvider. Call it once from the client bootstrap, before mounting or hydrating anything. Browser-only: it throws when called from server code, where a module-scoped translator would be shared by every concurrent request.
intl(handle: Handle<unknown, any>): I18n
From @sdxc/i18n/ui. Reads the I18n published by the nearest ancestor IntlProvider, falling back to the setIntl default, and throws when neither exists.
Trans
remix/ui component, from @sdxc/i18n/ui, for a message containing markup. Each {#name}…{/name} pair renders as the components[name] element with the content between them, nested markup included, as its children; a standalone {#name/} renders the element with no children.
intl: Translator to format through, typed or not; defaults to the nearest ancestorIntlProvider's (viaintl)i18nKey: Message key. Namedi18nKeybecausekeyisremix/ui's own reconciliation prop and never reaches the componentvalues: Values for the message's variablescomponents: Elements keyed by markup name
Markup with no components entry renders its children unwrapped and reports an error through the translator's onError, so under the middleware it lands on the request log.
Types
Messages, Translate, TranslateParts, MessageKey
interface Messages {
[key: string]: string | Messages;
}
type Translate<R = Messages> = (key: MessageKey<R>, values?: Record<string, unknown>) => string;
type TranslateParts<R = Messages> = (
key: MessageKey<R>,
values?: Record<string, unknown>,
) => MessagePart[];
MessageKey<R> is the union of dotted paths to R's string leaves, so with typed bundles a typo such as t("feeds.unraed") is a type error. createI18n types its keys by the bundle at resources[fallbackLanguage] when the fallback is a literal; other languages may hold any subset of those keys. Resources typed as Record<string, Messages>, or a fallback typed as string, give an untyped translator. IntlProvider, setIntl and Trans take I18n<any>, so a typed translator passes there; a function of your own that takes any translator does the same. Variables are not typed; a locale test that parses every message is the way to catch a missing one.
LanguageDetectorOptions
interface LanguageDetectorOptions {
supportedLanguages: string[];
fallbackLanguage: string;
cookie?: Cookie;
sessionCookie?: Cookie;
sessionStorage?: SessionStorage;
sessionKey?: string; // default "lng"
searchParamKey?: string; // default "lng"
order?: DetectionMethod[];
findLocale?(request: Request): Promise<string | string[] | null>;
}
supportedLanguages/fallbackLanguage: The languages detection may return, and the one returned when nothing matches; the middleware also resolves missing keys throughfallbackLanguagecookie:Cookie(fromremix/cookie) storing the preferred language as its plain valuesessionCookie+sessionStorage: Pair used to load the session outside middleware; unnecessary when a liveSessionis passed todetectorder: Which methods run and in what order; defaults tosearchParams,cookie,session,header, withcustomprepended whenfindLocaleis setfindLocale: Custom lookup for thecustommethod, such as a locale in the URL pathname; returningnulldefers to later methods
DetectionMethod
type DetectionMethod = "searchParams" | "cookie" | "session" | "header" | "custom";
I18nMiddlewareOptions, I18nOptions, and TranslatorOptions
The option objects taken by the middleware, createI18n, and createTranslator, field by field above.
Translation and Translator
interface Translation {
locale: string; // the language the copy is produced in
t: Translate;
intl: I18n; // the translator t belongs to
}
interface Translator {
(language?: string): Translation;
}
Pattern: Reusing the session from the session middleware
Order the i18n middleware after remix/middleware/session so the detector reads the language from the live request session: it needs no sessionCookie/sessionStorage configuration and performs no second storage read. Ordered before the session middleware, the session detection method is skipped instead.
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 sessionStorage = createCookieSessionStorage();
let router = createRouter({
middleware: [
session(sessionCookie, sessionStorage),
i18n({
detection: { supportedLanguages: ["en", "es"], fallbackLanguage: "en" },
resources: { en, es },
}),
],
});
Pattern: Letting the user pick a language
Store the choice in a dedicated cookie and hand that cookie to the detector. The searchParams method still runs first, so a ?lng= 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 localeCookie = createCookie("lng", { maxAge: 60 * 60 * 24 * 365 });
let router = createRouter({
middleware: [
i18n({
detection: {
supportedLanguages: ["en", "es"],
fallbackLanguage: "en",
cookie: localeCookie,
},
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 localeCookie.serialize(language),
},
});
});
Pattern: Locale from the URL pathname
findLocale covers path-based locales like /es/dashboard. Setting it prepends the custom method to the default order, so it runs before every other method; returning null defers to them.
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;
},
});