alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
446 lines (397 loc) • 14.4 kB
text/typescript
import { $hook, $inject, Alepha, TypeBoxError, TypeProvider, z } from "alepha";
import { type DateTime, DateTimeProvider } from "alepha/datetime";
import { $logger } from "alepha/logger";
// Locale-prefix routing is optional: the router module (one-directional
// dependency, `i18n → router`) is only consulted when it is also registered.
import { RouterLocaleProvider } from "alepha/react/router";
import { $cookie } from "alepha/server/cookies";
import type { ServiceDictionary } from "../hooks/useI18n.ts";
export class I18nProvider<
S extends object,
K extends keyof ServiceDictionary<S>,
> {
protected log = $logger();
protected alepha = $inject(Alepha);
protected dateTimeProvider = $inject(DateTimeProvider);
protected cookie = $cookie({
name: "lang",
schema: z.text(),
ttl: [1, "year"],
});
public readonly registry: Array<{
target: string;
name: string;
lang: string;
loader: () => Promise<Record<string, string>>;
translations: Record<string, string>;
}> = [];
options: {
fallbackLang: string;
autoDetect: boolean;
routing: "none" | "prefix";
} = {
fallbackLang: "en",
/**
* When true (the default), the UI language for a first-time visitor (one
* with no `lang` cookie) is detected server-side from the `Accept-Language`
* header. A manually-selected language (the cookie) always takes
* precedence, so this never overrides an explicit user choice. Set to false
* to always start in `fallbackLang` regardless of the browser's preferred
* language.
*/
autoDetect: true,
/**
* URL strategy for languages:
* - `"none"` (default): language lives in a cookie; URLs are not localized.
* - `"prefix"`: each non-default language gets a path prefix (`/fr/about`),
* making every language a distinct, crawlable URL for SEO. The default
* language (`fallbackLang`) stays unprefixed. Requires the router module.
* The URL becomes the source of truth for language (it wins over the
* cookie / `Accept-Language`), and there is no automatic redirect.
*/
routing: "none",
};
/**
* Lazily-resolved locale-prefix router integration. Present only when both
* the router module is registered AND `routing: "prefix"` was configured —
* otherwise i18n stays fully standalone.
*/
protected localeProviderResolved = false;
protected localeProviderRef?: RouterLocaleProvider;
protected get localeProvider(): RouterLocaleProvider | undefined {
if (!this.localeProviderResolved) {
this.localeProviderResolved = true;
if (this.alepha.has(RouterLocaleProvider)) {
this.localeProviderRef = this.alepha.inject(RouterLocaleProvider);
}
}
return this.localeProviderRef;
}
public dateFormat: { format: (value: Date) => string } =
new Intl.DateTimeFormat(this.lang);
public numberFormat: { format: (value: number) => string } =
new Intl.NumberFormat(this.lang);
public get languages() {
const languages = new Set<string>();
for (const item of this.registry) {
languages.add(item.lang);
}
return Array.from(languages);
}
constructor() {
this.refreshLocale();
}
/**
* Configure locale-prefix routing on the router, before the SSR routes are
* registered (`priority: "first"` runs ahead of the router's own `configure`
* hook). No-op unless `routing: "prefix"` and the router module is present.
*/
protected readonly onConfigure = $hook({
on: "configure",
priority: "first",
handler: () => {
const localeProvider = this.localeProvider;
if (this.options.routing === "prefix" && localeProvider) {
localeProvider.configure({
enabled: true,
defaultLocale: this.fallbackLang,
locales: this.languages,
});
}
},
});
protected readonly onRender = $hook({
on: "server:onRequest",
priority: "last",
handler: async ({ request }) => {
this.alepha.store.set(
"alepha.react.i18n.lang",
this.resolveRequestLang(
this.cookie.get(request),
request.language,
this.detectUrlLocale(request.url?.pathname),
),
);
},
});
/**
* Detects the language carried by the request URL when `routing: "prefix"` is
* active. Returns the locale for any URL (the prefixed one for `/fr/...`, the
* default for an unprefixed path), or `undefined` when prefix routing is off.
*/
protected detectUrlLocale(pathname: string | undefined): string | undefined {
const localeProvider = this.localeProvider;
if (localeProvider?.enabled && pathname) {
return localeProvider.detect(pathname).locale || undefined;
}
return undefined;
}
/**
* Resolves the UI language for an incoming server request.
*
* Priority:
* 0. the URL locale prefix (`routing: "prefix"`) — the URL is the source of
* truth and wins over everything, with no redirect;
* 1. the `lang` cookie — a language the user manually selected;
* 2. the `Accept-Language` header (when `autoDetect` is enabled) — but only
* when the detected language is actually registered, so we never switch to
* a locale we have no dictionary for. A region-qualified header (`en-US`)
* matches an exact registration first, then its base language (`en`);
* 3. `fallbackLang`.
*/
protected resolveRequestLang(
cookieLang: string | undefined,
headerLang: string | undefined,
urlLocale?: string,
): string {
if (urlLocale) {
return urlLocale;
}
if (cookieLang) {
return cookieLang;
}
if (this.options.autoDetect && headerLang) {
const registered = this.languages;
for (const candidate of [headerLang, headerLang.split("-")[0]]) {
if (registered.includes(candidate)) {
return candidate;
}
}
}
return this.fallbackLang;
}
protected readonly onStart = $hook({
on: "start",
handler: async () => {
if (this.alepha.isBrowser()) {
// In prefix mode the URL (hydrated into the lang state by the server)
// is the source of truth, so the cookie must not override it.
if (!this.localeProvider?.enabled) {
const cookieLang = this.cookie.get();
if (cookieLang) {
this.alepha.store.set("alepha.react.i18n.lang", cookieLang);
}
}
for (const item of this.registry) {
if (item.lang === this.lang || item.lang === this.fallbackLang) {
this.log.trace("Loading language", {
lang: item.lang,
name: item.name,
target: item.target,
});
item.translations = await item.loader();
}
}
return;
}
for (const item of this.registry) {
item.translations = await item.loader();
}
},
});
protected refreshLocale() {
this.numberFormat = new Intl.NumberFormat(this.lang);
this.dateFormat = new Intl.DateTimeFormat(this.lang);
this.dateTimeProvider.setLocale(this.lang);
TypeProvider.setLocale(this.lang);
}
/**
* Activates a language: lazily loads its dictionaries (browser), updates the
* lang state, and refreshes the locale-bound formatters. Does NOT persist a
* cookie or navigate — that is the caller's concern.
*/
protected applyLang = async (lang: string) => {
if (this.alepha.isBrowser()) {
for (const item of this.registry) {
if (lang === item.lang && Object.keys(item.translations).length === 0) {
item.translations = await item.loader();
}
}
}
this.alepha.store.set("alepha.react.i18n.lang", lang);
this.refreshLocale();
};
public setLang = async (lang: string) => {
const localeProvider = this.localeProvider;
if (localeProvider?.enabled) {
// The URL is the source of truth: switching language means navigating to
// the same page under the new locale prefix. Dictionaries and formatters
// are then activated via the resulting `alepha.react.router.locale`
// change (see the `mutate` hook). No cookie is written in prefix mode.
const { ReactRouter } = await import("alepha/react/router");
const router = this.alepha.inject(ReactRouter);
const canonical = localeProvider.detect(router.pathname).pathname;
await router.push(localeProvider.withPrefix(canonical, lang));
return;
}
await this.applyLang(lang);
if (this.alepha.isBrowser()) {
this.cookie.set(lang);
}
};
protected readonly mutate = $hook({
on: "state:mutate",
handler: async ({ key, value }) => {
// Prefix-mode navigation changed the active locale (set by the router) →
// activate the matching language (loads dictionaries, refreshes
// formatters, and re-renders consumers via the lang state).
if (
key === "alepha.react.router.locale" &&
this.localeProvider?.enabled
) {
const lang = (value as string) || this.fallbackLang;
if (lang !== this.lang) {
await this.applyLang(lang);
}
return;
}
if (key === "alepha.react.i18n.lang" && this.alepha.isBrowser()) {
let hasChanged = false;
for (const item of this.registry) {
if (value === item.lang) {
if (Object.keys(item.translations).length > 0) {
continue; // already loaded
}
item.translations = await item.loader();
hasChanged = true;
}
}
this.refreshLocale();
if (hasChanged) {
this.alepha.store.set("alepha.react.i18n.lang", value);
}
}
},
});
public get fallbackLang(): string {
const configured = this.options.fallbackLang;
const hasDict = this.registry.some((item) => item.lang === configured);
if (hasDict) {
return configured;
}
return this.registry[0]?.lang ?? configured;
}
public get lang(): string {
return this.alepha.store.get("alepha.react.i18n.lang") || this.fallbackLang;
}
public translate = (key: string, args: string[] = []) => {
for (const item of this.registry) {
if (item.lang === this.lang) {
if (item.translations[key]) {
return this.render(item.translations[key], args); // append lang for fallback
}
}
}
for (const item of this.registry) {
if (item.lang === this.fallbackLang) {
if (item.translations[key]) {
return this.render(item.translations[key], args); // append lang for fallback
}
}
}
return key; // fallback to the key itself if not found
};
public readonly l = (
value: I18nLocalizeType,
options: I18nLocalizeOptions = {},
) => {
// Handle numbers
if (typeof value === "number" && !options.date) {
return new Intl.NumberFormat(this.lang, options.number).format(value);
}
// Handle dates
if (
value instanceof Date ||
this.dateTimeProvider.isDateTime(value) ||
(typeof value === "string" && options.date) ||
(typeof value === "number" && options.date)
) {
// convert to DateTime with locale applied
let dt = this.dateTimeProvider.of(value);
// apply timezone if specified
if (options.timezone) {
dt = dt.tz(options.timezone);
}
// format using dayjs format string
if (typeof options.date === "string") {
if (options.date === "fromNow") {
return dt.locale(this.lang).fromNow();
}
return dt.locale(this.lang).format(options.date);
}
// format using Intl.DateTimeFormatOptions
if (options.date) {
return new Intl.DateTimeFormat(
this.lang,
options.timezone
? { ...options.date, timeZone: options.timezone }
: options.date,
).format(dt.toDate());
}
// default formatting with timezone
if (options.timezone) {
return new Intl.DateTimeFormat(this.lang, {
timeZone: options.timezone,
}).format(dt.toDate());
}
// default formatting
return new Intl.DateTimeFormat(this.lang).format(dt.toDate());
}
// handle TypeBox errors
if (value instanceof TypeBoxError) {
return TypeProvider.translateError(value, this.lang);
}
// return string values as-is
return value;
};
/**
* Look up `key` in the registered dictionaries. The `(string & {})` arm
* keeps autocomplete for the typed dictionary keys while allowing shared
* library components to pass arbitrary string keys (with a `default`
* fallback) without casting to `as never`.
*/
public readonly tr = (
key: keyof ServiceDictionary<S>[K] | (string & {}),
options: {
args?: string[];
default?: string;
} = {},
) => {
const translation = this.translate(key as string, options.args || []);
if (translation === (key as string) && options.default) {
return options.default;
}
return translation;
};
protected render(item: string, args: string[]): string {
let result = item;
for (let i = 0; i < args.length; i++) {
result = result.replace(`$${i + 1}`, args[i]);
}
return result;
}
}
export type I18nLocalizeType = string | number | Date | DateTime | TypeBoxError;
export interface I18nLocalizeOptions {
/**
* Options for number formatting (when value is a number)
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat
*/
number?: Intl.NumberFormatOptions;
/**
* Options for date formatting (when value is a Date or DateTime)
* Can be:
* - A dayjs format string (e.g., "LLL", "YYYY-MM-DD", "dddd, MMMM D YYYY")
* - "fromNow" for relative time (e.g., "2 hours ago")
* - Intl.DateTimeFormatOptions for native formatting
* @see https://day.js.org/docs/en/display/format
* @see https://day.js.org/docs/en/display/from-now
*/
date?: string | "fromNow" | Intl.DateTimeFormatOptions;
/**
* Timezone to display dates in (when value is a Date or DateTime)
* Uses IANA timezone names (e.g., "America/New_York", "Europe/Paris", "Asia/Tokyo")
* @see https://day.js.org/docs/en/timezone/timezone
*/
timezone?: string;
}