UNPKG

alepha

Version:

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

360 lines (359 loc) 12.7 kB
import { $hook, $inject, $module, Alepha, KIND, Primitive, TypeBoxError, TypeProvider, createPrimitive, z } from "alepha"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { RouterLocaleProvider } from "alepha/react/router"; import { $cookie } from "alepha/server/cookies"; import { useInject, useStore } from "alepha/react"; import { Fragment, jsx } from "react/jsx-runtime"; //#region ../../src/react/i18n/providers/I18nProvider.ts var I18nProvider = class { log = $logger(); alepha = $inject(Alepha); dateTimeProvider = $inject(DateTimeProvider); cookie = $cookie({ name: "lang", schema: z.text(), ttl: [1, "year"] }); registry = []; options = { 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. */ localeProviderResolved = false; localeProviderRef; get localeProvider() { if (!this.localeProviderResolved) { this.localeProviderResolved = true; if (this.alepha.has(RouterLocaleProvider)) this.localeProviderRef = this.alepha.inject(RouterLocaleProvider); } return this.localeProviderRef; } dateFormat = new Intl.DateTimeFormat(this.lang); numberFormat = new Intl.NumberFormat(this.lang); get languages() { const languages = /* @__PURE__ */ new Set(); 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. */ 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 }); } }); 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. */ detectUrlLocale(pathname) { const localeProvider = this.localeProvider; if (localeProvider?.enabled && pathname) return localeProvider.detect(pathname).locale || void 0; } /** * 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`. */ resolveRequestLang(cookieLang, headerLang, urlLocale) { 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; } onStart = $hook({ on: "start", handler: async () => { if (this.alepha.isBrowser()) { 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(); } }); 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. */ applyLang = async (lang) => { 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(); }; setLang = async (lang) => { const localeProvider = this.localeProvider; if (localeProvider?.enabled) { 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); }; mutate = $hook({ on: "state:mutate", handler: async ({ key, value }) => { if (key === "alepha.react.router.locale" && this.localeProvider?.enabled) { const lang = value || 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; item.translations = await item.loader(); hasChanged = true; } this.refreshLocale(); if (hasChanged) this.alepha.store.set("alepha.react.i18n.lang", value); } } }); get fallbackLang() { const configured = this.options.fallbackLang; if (this.registry.some((item) => item.lang === configured)) return configured; return this.registry[0]?.lang ?? configured; } get lang() { return this.alepha.store.get("alepha.react.i18n.lang") || this.fallbackLang; } translate = (key, args = []) => { for (const item of this.registry) if (item.lang === this.lang) { if (item.translations[key]) return this.render(item.translations[key], args); } for (const item of this.registry) if (item.lang === this.fallbackLang) { if (item.translations[key]) return this.render(item.translations[key], args); } return key; }; l = (value, options = {}) => { if (typeof value === "number" && !options.date) return new Intl.NumberFormat(this.lang, options.number).format(value); if (value instanceof Date || this.dateTimeProvider.isDateTime(value) || typeof value === "string" && options.date || typeof value === "number" && options.date) { let dt = this.dateTimeProvider.of(value); if (options.timezone) dt = dt.tz(options.timezone); if (typeof options.date === "string") { if (options.date === "fromNow") return dt.locale(this.lang).fromNow(); return dt.locale(this.lang).format(options.date); } if (options.date) return new Intl.DateTimeFormat(this.lang, options.timezone ? { ...options.date, timeZone: options.timezone } : options.date).format(dt.toDate()); if (options.timezone) return new Intl.DateTimeFormat(this.lang, { timeZone: options.timezone }).format(dt.toDate()); return new Intl.DateTimeFormat(this.lang).format(dt.toDate()); } if (value instanceof TypeBoxError) return TypeProvider.translateError(value, this.lang); 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`. */ tr = (key, options = {}) => { const translation = this.translate(key, options.args || []); if (translation === key && options.default) return options.default; return translation; }; render(item, args) { let result = item; for (let i = 0; i < args.length; i++) result = result.replace(`$${i + 1}`, args[i]); return result; } }; //#endregion //#region ../../src/react/i18n/primitives/$dictionary.ts /** * Register a dictionary entry for translations. * * It allows you to define a set of translations for a specific language. * Entry can be lazy-loaded, which is useful for large dictionaries or when translations are not needed immediately. * * @example * ```ts * import { $dictionary } from "alepha/react/i18n"; * * const Example = () => { * const { tr } = useI18n<App, "en">(); * return <div>{tr("hello")}</div>; // * } * * class App { * * en = $dictionary({ * // { default: { hello: "Hey" } } * lazy: () => import("./translations/en.ts"), * }); * * home = $page({ * path: "/", * component: Example, * }) * } * * run(App); * ``` */ const $dictionary = (options) => { return createPrimitive(DictionaryPrimitive, options); }; var DictionaryPrimitive = class extends Primitive { provider = $inject(I18nProvider); onInit() { this.provider.registry.push({ target: this.config.service.name, name: this.options.name ?? this.config.propertyKey, lang: this.options.lang ?? this.config.propertyKey, loader: async () => { return (await this.options.lazy()).default; }, translations: {} }); } }; $dictionary[KIND] = DictionaryPrimitive; //#endregion //#region ../../src/react/i18n/hooks/useI18n.ts /** * Hook to access the i18n service. */ const useI18n = () => { useStore("alepha.react.i18n.lang"); return useInject(I18nProvider); }; //#endregion //#region ../../src/react/i18n/components/Localize.tsx const Localize = (props) => { return useI18n().l(props.value, props); }; //#endregion //#region ../../src/react/i18n/components/Translate.tsx /** * Renders a translated dictionary entry as a reactive text node. * * `tr(...)` is a hook, so it can only be called inside a component body. Use * `<Translate>` wherever a *node* is expected instead — a prop, a `children` * slot, or route metadata such as a nav-shell `label`. Because it subscribes to * {@link useI18n} it re-renders on a language switch, unlike a string resolved * once at module/initialisation time. * * Sibling of {@link Localize}: `Localize` formats a value (number, date, * error); `Translate` looks up a key. * * @example * ```tsx * nav: { label: <Translate k="admin.nav.users" /> } * <Translate k="cart.items" args={[String(count)]} fallback="Items" /> * ``` */ const Translate = (props) => { const { tr } = useI18n(); return /* @__PURE__ */ jsx(Fragment, { children: tr(props.k, { args: props.args, default: props.fallback }) }); }; /** * Terse alias for {@link Translate} — handy in dense JSX such as nav labels. */ const Tr = Translate; //#endregion //#region ../../src/react/i18n/index.ts /** * Multi-language support. * * **Features:** * - Translation loading * - Locale detection * - Pluralization * * @module alepha.react.i18n */ const AlephaReactI18n = $module({ name: "alepha.react.i18n", primitives: [$dictionary], services: [I18nProvider] }); //#endregion export { $dictionary, AlephaReactI18n, DictionaryPrimitive, I18nProvider, Localize, Tr, Translate, useI18n }; //# sourceMappingURL=index.js.map