UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

446 lines (397 loc) 14.4 kB
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; }