alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
360 lines (359 loc) • 12.7 kB
JavaScript
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