@zerotal/i18n 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog — @zerotal/i18n
2
+
3
+ All notable changes to this package are documented here. The format is
4
+ based on [Keep a Changelog](https://keepachangelog.com/); this package
5
+ follows the Zerotal monorepo's unified versioning.
6
+
7
+ **Maturity: `beta`**
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [1.0.0] — 2026-08-05
12
+
13
+ _First public release._
14
+
15
+ ### Added
16
+
17
+ - Initial release: request-scoped localization for Zerotal.
18
+ - `Translator` with dot-path lookup, fallback-locale resolution, `{var}` / `:var`
19
+ interpolation, and pipe-segment pluralization (`"none | one | {count} many"`).
20
+ - `I18nConfig` factory (`defaultLocale`, `fallbackLocale`, `supportedLocales`,
21
+ `resolvers`, `loadPath`, in-memory `catalogs`).
22
+ - `loadCatalogs()` — loads `<locale>.json` files from a directory.
23
+ - Request locale resolution via query string, cookie, and `Accept-Language`.
24
+ - `I18nProvider` + `LocaleMiddleware` — resolve the locale per request and inject
25
+ `ctx.t()` / `ctx.locale`; AsyncLocalStorage-backed `I18nContext` for the
26
+ `Lang` facade and the global `t()` helper.
27
+ - Typed error vocabulary (`I18nError` / `CatalogLoadError`, `E_I18N*` codes).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zerotal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # @zerotal/i18n
2
+
3
+ > Request-scoped internationalization with interpolation, pluralization, and fallback.
4
+
5
+ Resolves each visitor's locale automatically from the query string, a cookie, or
6
+ the `Accept-Language` header, then translates message keys with interpolation,
7
+ pluralization, and locale fallback. Translations are available on the request
8
+ context (`http.t`), through the `Lang` facade, and via the global `t()` helper.
9
+
10
+ Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ bun add @zerotal/i18n
16
+ ```
17
+
18
+ ## Setup
19
+
20
+ Register the provider in `bootstrap/providers.ts`:
21
+
22
+ ```ts
23
+ import { I18nProvider } from "@zerotal/i18n";
24
+
25
+ export default [I18nProvider];
26
+ ```
27
+
28
+ `I18nProvider` registers `LocaleMiddleware`, which resolves the locale for every
29
+ request and injects `ctx.t()` and `ctx.locale`. Configure it in `config/i18n.ts`:
30
+
31
+ ```ts
32
+ // config/i18n.ts
33
+ import { I18nConfig } from "@zerotal/i18n";
34
+ import { env } from "@zerotal/core";
35
+
36
+ export default I18nConfig({
37
+ defaultLocale: env("APP_LOCALE", "en"),
38
+ fallbackLocale: env("APP_FALLBACK_LOCALE", "en"),
39
+ supportedLocales: ["en", "fr", "es"],
40
+ resolvers: ["query", "cookie", "accept-header"], // tried in order
41
+ queryKey: "lang", // ?lang=fr
42
+ cookieKey: "locale", // locale=fr cookie
43
+ loadPath: "resources/lang", // <locale>.json catalogs
44
+ });
45
+ ```
46
+
47
+ ## Usage
48
+
49
+ Translate from a controller via the request context:
50
+
51
+ ```ts
52
+ async show({ http }: Context) {
53
+ http.t("welcome.greeting", { name: "Alice" }); // active locale
54
+ http.t("welcome.greeting", { name: "Alice" }, "fr"); // explicit locale
55
+ return http.response.json({ locale: http.locale });
56
+ }
57
+ ```
58
+
59
+ Anywhere else, use the `Lang` facade or the global `t()` helper — both honour the
60
+ active request locale via `I18nContext`:
61
+
62
+ ```ts
63
+ import { Lang, t } from "@zerotal/i18n";
64
+
65
+ Lang.translate("auth.login.title");
66
+ t("dashboard.welcome");
67
+
68
+ // Override the resolved locale for the current request:
69
+ Lang.setLocale("fr");
70
+ ```
71
+
72
+ Catalogs support nested or flat dotted keys, `{name}`/`:name` interpolation, and
73
+ pipe-separated pluralization chosen by `count`:
74
+
75
+ ```json
76
+ {
77
+ "welcome": { "greeting": "Hello, {name}!" },
78
+ "validation.required": "The :field field is required.",
79
+ "apples": "no apples | one apple | {count} apples"
80
+ }
81
+ ```
82
+
83
+ ```ts
84
+ t("apples", { count: 0 }); // "no apples"
85
+ t("apples", { count: 5 }); // "5 apples"
86
+ ```
87
+
88
+ A key missing in the active locale falls back to `fallbackLocale`; if still
89
+ missing, the key itself is returned (gaps are visible, never thrown).
90
+
91
+ ## Exports
92
+
93
+ - `Translator` — the core translation service (and `TranslatorOptions`).
94
+ - `I18nProvider` — registers `LocaleMiddleware` and binds the translator.
95
+ - `LocaleMiddleware` — resolves the locale per request and injects `ctx.t`/`ctx.locale`.
96
+ - `I18nContext` — async-local storage holding the active request locale.
97
+ - `Lang`, `t` — facade and global helper that read the active locale.
98
+ - `I18nConfig` / `I18nConfigShape` — config factory and its shape.
99
+ - `loadCatalogs` — load `<locale>.json` catalogs from disk.
100
+ - `resolveLocale`, `parseAcceptLanguage` — locale resolution helpers.
101
+ - Types: `Messages`, `Catalogs`, `Replacements`, `LocaleResolver`.
102
+ - Errors: `I18nError` (`E_I18N`), `CatalogLoadError` (`E_I18N_CATALOG_LOAD`).
103
+
104
+ ## Documentation
105
+
106
+ - [Internationalization](../../docs/i18n.md)
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@zerotal/i18n",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "maturity": "beta",
6
+ "private": false,
7
+ "type": "module",
8
+ "main": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "exports": {
11
+ ".": "./src/index.ts"
12
+ },
13
+ "files": [
14
+ "CHANGELOG.md",
15
+ "src",
16
+ "!src/**/*.test.ts",
17
+ "!src/**/*.test.tsx",
18
+ "!src/**/*.spec.ts",
19
+ "!src/**/__fixtures__/**"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "engines": {
25
+ "bun": ">=1.3.14"
26
+ },
27
+ "sideEffects": [
28
+ "./src/augment.ts"
29
+ ],
30
+ "scripts": {
31
+ "test": "bun test",
32
+ "typecheck": "tsc --noEmit"
33
+ },
34
+ "dependencies": {
35
+ "@zerotal/core": "1.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "typescript": "^5.8.0"
39
+ },
40
+ "description": "Internationalization and localization for Zerotal applications.",
41
+ "keywords": [
42
+ "zerotal",
43
+ "bun",
44
+ "typescript",
45
+ "framework"
46
+ ],
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/zerotaldev/zerotal.git",
50
+ "directory": "packages/i18n"
51
+ },
52
+ "homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/i18n#readme",
53
+ "bugs": "https://github.com/zerotaldev/zerotal/issues"
54
+ }
@@ -0,0 +1,20 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ /**
4
+ * Request-scoped active locale. `LocaleMiddleware` runs each request inside
5
+ * `I18nContext.run(locale, …)` so the `Lang` facade and the global `t()` helper
6
+ * resolve to the right language without threading it through every call.
7
+ */
8
+ const _storage = new AsyncLocalStorage<string>();
9
+
10
+ export class I18nContext {
11
+ /** Execute `callback` with `locale` as the active request locale. */
12
+ static run<T>(locale: string, callback: () => T): T {
13
+ return _storage.run(locale, callback);
14
+ }
15
+
16
+ /** The active request locale, or undefined outside a request boundary. */
17
+ static current(): string | undefined {
18
+ return _storage.getStore();
19
+ }
20
+ }
@@ -0,0 +1,33 @@
1
+ import type { Pipe, NextFn, HttpContext } from "@zerotal/core";
2
+ import { resolveLocale } from "./locale.ts";
3
+ import { I18nContext } from "./I18nContext.ts";
4
+ import type { Translator } from "./Translator.ts";
5
+ import type { I18nConfigShape } from "./config.ts";
6
+
7
+ /**
8
+ * Resolves the request locale, exposes `ctx.locale` + `ctx.t()`, and runs the
9
+ * rest of the pipeline inside `I18nContext.run()` so the `Lang` facade and the
10
+ * global `t()` helper see the right locale. Configured by I18nProvider via
11
+ * `LocaleMiddleware.configure()` and registered with `app.useOnce()`.
12
+ */
13
+ export class LocaleMiddleware implements Pipe<HttpContext> {
14
+ private static _translator: Translator | null = null;
15
+ private static _config: I18nConfigShape | null = null;
16
+
17
+ static configure(translator: Translator, config: I18nConfigShape): void {
18
+ LocaleMiddleware._translator = translator;
19
+ LocaleMiddleware._config = config;
20
+ }
21
+
22
+ async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
23
+ const translator = LocaleMiddleware._translator;
24
+ const config = LocaleMiddleware._config;
25
+ if (!translator || !config) return next();
26
+
27
+ const locale = resolveLocale(http.request, config);
28
+ http.locale = locale;
29
+ http.t = (key, replacements, loc) => translator.translate(key, replacements, loc ?? locale);
30
+
31
+ return I18nContext.run(locale, () => next());
32
+ }
33
+ }
@@ -0,0 +1,107 @@
1
+ import { I18nContext } from "./I18nContext.ts";
2
+ import type { Catalogs, Messages, Replacements } from "./types.ts";
3
+
4
+ export interface TranslatorOptions {
5
+ catalogs: Catalogs;
6
+ defaultLocale: string;
7
+ fallbackLocale: string;
8
+ }
9
+
10
+ /**
11
+ * Resolves translation keys against per-locale catalogs, with fallback,
12
+ * interpolation, and pluralization. Resolved from the container as `i18n`.
13
+ */
14
+ export class Translator {
15
+ private readonly catalogs: Catalogs;
16
+ readonly defaultLocale: string;
17
+ readonly fallbackLocale: string;
18
+
19
+ constructor(opts: TranslatorOptions) {
20
+ this.catalogs = opts.catalogs;
21
+ this.defaultLocale = opts.defaultLocale;
22
+ this.fallbackLocale = opts.fallbackLocale;
23
+ }
24
+
25
+ /** Loaded locales. */
26
+ get locales(): string[] {
27
+ return Object.keys(this.catalogs);
28
+ }
29
+
30
+ /** Merge messages into a locale's catalog (used by tooling/tests). */
31
+ addCatalog(locale: string, messages: Messages): void {
32
+ this.catalogs[locale] = { ...(this.catalogs[locale] ?? {}), ...messages };
33
+ }
34
+
35
+ /** Active locale: explicit arg → request context → default. */
36
+ private _resolve(locale?: string): string {
37
+ return locale ?? I18nContext.current() ?? this.defaultLocale;
38
+ }
39
+
40
+ /** True if `key` exists in the given (or active) locale or the fallback. */
41
+ has(key: string, locale?: string): boolean {
42
+ const loc = this._resolve(locale);
43
+ return (
44
+ this._lookup(key, loc) !== undefined || this._lookup(key, this.fallbackLocale) !== undefined
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Translate `key`. Missing keys return the key itself (so the UI degrades
50
+ * visibly rather than throwing).
51
+ *
52
+ * @example
53
+ * t.translate('welcome.greeting', { name: 'Alice' }, 'fr');
54
+ * t.translate('apples', { count: 2 }); // pipe-pluralized
55
+ */
56
+ translate(key: string, replacements: Replacements = {}, locale?: string): string {
57
+ const loc = this._resolve(locale);
58
+ let msg = this._lookup(key, loc);
59
+ if (msg === undefined && loc !== this.fallbackLocale) {
60
+ msg = this._lookup(key, this.fallbackLocale);
61
+ }
62
+ if (msg === undefined) return key;
63
+ if (typeof replacements.count === "number") {
64
+ msg = this._pluralize(msg, replacements.count);
65
+ }
66
+ return this._interpolate(msg, replacements);
67
+ }
68
+
69
+ /** Look up a key as a flat dotted key first, then as a nested path. */
70
+ private _lookup(key: string, locale: string): string | undefined {
71
+ const cat = this.catalogs[locale];
72
+ if (!cat) return undefined;
73
+ const flat = (cat as Record<string, unknown>)[key];
74
+ if (typeof flat === "string") return flat;
75
+ let node: unknown = cat;
76
+ for (const part of key.split(".")) {
77
+ if (node && typeof node === "object" && part in (node as object)) {
78
+ node = (node as Record<string, unknown>)[part];
79
+ } else {
80
+ return undefined;
81
+ }
82
+ }
83
+ return typeof node === "string" ? node : undefined;
84
+ }
85
+
86
+ /**
87
+ * Choose a pipe-separated segment by count.
88
+ * - 2 segments: `singular | plural` (count === 1 → first).
89
+ * - 3+ segments: `zero | one | many` (count 0 → first, 1 → second, else last).
90
+ */
91
+ private _pluralize(message: string, count: number): string {
92
+ const segs = message.split("|").map((s) => s.trim());
93
+ if (segs.length === 1) return segs[0]!;
94
+ if (segs.length === 2) return count === 1 ? segs[0]! : segs[1]!;
95
+ if (count === 0) return segs[0]!;
96
+ if (count === 1) return segs[1]!;
97
+ return segs[segs.length - 1]!;
98
+ }
99
+
100
+ /** Replace `{name}` and `:name` placeholders from `replacements`. */
101
+ private _interpolate(message: string, replacements: Replacements): string {
102
+ return message.replace(/\{(\w+)\}|:(\w+)/g, (match, curly: string, colon: string) => {
103
+ const key = curly ?? colon;
104
+ return key in replacements ? String(replacements[key]) : match;
105
+ });
106
+ }
107
+ }
package/src/augment.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { Translator } from "./Translator.ts";
2
+ import type { Replacements } from "./types.ts";
3
+
4
+ declare module "@zerotal/core" {
5
+ interface ContainerBindings {
6
+ /** The translation service — registered by I18nProvider. */
7
+ i18n: Translator;
8
+ }
9
+
10
+ interface HttpContext {
11
+ /**
12
+ * Translate a key using this request's resolved locale.
13
+ *
14
+ * @example
15
+ * ctx.t('welcome.greeting', { name: 'Alice' });
16
+ * ctx.t('welcome.greeting', { name: 'Alice' }, 'fr'); // explicit locale
17
+ */
18
+ t(key: string, replacements?: Replacements, locale?: string): string;
19
+ }
20
+ }
21
+
22
+ export {};
package/src/config.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { deepMerge } from "@zerotal/core";
2
+ import type { Catalogs, LocaleResolver } from "./types.ts";
3
+
4
+ export interface I18nConfigShape {
5
+ /** Locale used when no resolver matches. */
6
+ defaultLocale: string;
7
+ /** Locale consulted when a key is missing in the active locale. */
8
+ fallbackLocale: string;
9
+ /** Locales the app accepts; resolvers only return one of these. */
10
+ supportedLocales: string[];
11
+ /** Request resolvers, tried in order. Default: query -> cookie -> accept-header. */
12
+ resolvers: LocaleResolver[];
13
+ /** Query-string key for the `query` resolver. Default: `lang`. */
14
+ queryKey: string;
15
+ /** Cookie name for the `cookie` resolver. Default: `locale`. */
16
+ cookieKey: string;
17
+ /** Directory of `<locale>.json` catalogs, loaded on boot. */
18
+ loadPath?: string;
19
+ /** In-memory catalogs, merged over anything loaded from `loadPath`. */
20
+ catalogs?: Catalogs;
21
+ }
22
+
23
+ /**
24
+ * Build the i18n configuration block.
25
+ *
26
+ * @example
27
+ * // config/i18n.ts
28
+ * import { I18nConfig } from '@zerotal/i18n';
29
+ * export default I18nConfig({
30
+ * supportedLocales: ['en', 'fr', 'es'],
31
+ * loadPath: 'resources/lang',
32
+ * });
33
+ */
34
+ export function I18nConfig(options: Partial<I18nConfigShape> = {}): I18nConfigShape {
35
+ // `fallbackLocale` and `supportedLocales` default off the chosen `defaultLocale`,
36
+ // so the defaults are computed before the merge. deepMerge then applies any
37
+ // explicit overrides (and replaces the `supportedLocales`/`resolvers` arrays).
38
+ const defaultLocale = options.defaultLocale ?? "en";
39
+ const defaults: I18nConfigShape = {
40
+ defaultLocale,
41
+ fallbackLocale: defaultLocale,
42
+ supportedLocales: [defaultLocale],
43
+ resolvers: ["query", "cookie", "accept-header"],
44
+ queryKey: "lang",
45
+ cookieKey: "locale",
46
+ };
47
+ return deepMerge(defaults, options);
48
+ }
49
+
50
+ // Register this package's config namespace for typed config() dot-paths.
51
+ declare module "@zerotal/core" {
52
+ interface ConfigRegistry {
53
+ i18n: I18nConfigShape;
54
+ }
55
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { ZerotalError } from "@zerotal/core";
2
+
3
+ /** Base class for all @zerotal/i18n errors. */
4
+ export class I18nError extends ZerotalError {
5
+ constructor(message: string, code = "E_I18N", status = 500, context?: Record<string, unknown>) {
6
+ super(message, code, status, context);
7
+ }
8
+ }
9
+
10
+ /** Thrown when a catalog file exists but cannot be read/parsed as JSON. */
11
+ export class CatalogLoadError extends I18nError {
12
+ constructor(file: string, cause: unknown) {
13
+ super(
14
+ `[Zerotal i18n] Failed to load catalog "${file}": ${(cause as Error)?.message ?? String(cause)}`,
15
+ "E_I18N_CATALOG_LOAD",
16
+ 500,
17
+ { file },
18
+ );
19
+ }
20
+ }
@@ -0,0 +1,22 @@
1
+ import { createFacade } from "@zerotal/core";
2
+ import type { Replacements } from "../types.ts";
3
+
4
+ /**
5
+ * Facade for the translation service (container key `i18n`). Resolves against
6
+ * the active request locale via I18nContext.
7
+ *
8
+ * @example
9
+ * import { Lang } from '@zerotal/i18n';
10
+ * Lang.translate('welcome.greeting', { name: 'Alice' });
11
+ */
12
+ export const Lang = createFacade<"i18n">("i18n");
13
+
14
+ /**
15
+ * Convenience global translation helper — handy as a view global.
16
+ *
17
+ * @example
18
+ * t('messages.unread', { count: 5 });
19
+ */
20
+ export function t(key: string, replacements?: Replacements, locale?: string): string {
21
+ return Lang.translate(key, replacements, locale);
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ // @zerotal/i18n — public API
2
+
3
+ // Core service
4
+ export { Translator } from "./Translator.ts";
5
+ export type { TranslatorOptions } from "./Translator.ts";
6
+
7
+ // Request-scoped locale
8
+ export { I18nContext } from "./I18nContext.ts";
9
+
10
+ // Provider + middleware
11
+ export { I18nProvider } from "./provider/I18nProvider.ts";
12
+ export { LocaleMiddleware } from "./LocaleMiddleware.ts";
13
+
14
+ // Facade + helper
15
+ export { Lang, t } from "./facades/Lang.ts";
16
+
17
+ // Config
18
+ export { I18nConfig } from "./config.ts";
19
+ export type { I18nConfigShape } from "./config.ts";
20
+
21
+ // Catalog loading + locale resolution
22
+ export { loadCatalogs } from "./loadCatalogs.ts";
23
+ export { resolveLocale, parseAcceptLanguage } from "./locale.ts";
24
+
25
+ // Types
26
+ export type { Messages, Catalogs, Replacements, LocaleResolver } from "./types.ts";
27
+
28
+ // Typed error vocabulary
29
+ export * from "./errors.ts";
30
+
31
+ // Side-effect: activate @zerotal/core HttpContext + ContainerBindings augmentation.
32
+ import "./augment.ts";
@@ -0,0 +1,35 @@
1
+ import type { Catalogs, Messages } from "./types.ts";
2
+ import { CatalogLoadError } from "./errors.ts";
3
+
4
+ /**
5
+ * Load `<locale>.json` files from `dir` into a catalog map. A missing directory
6
+ * yields an empty map; a malformed JSON file throws `CatalogLoadError`.
7
+ *
8
+ * @example
9
+ * const catalogs = await loadCatalogs('resources/lang'); // { en: {...}, fr: {...} }
10
+ */
11
+ export async function loadCatalogs(dir: string): Promise<Catalogs> {
12
+ const out: Catalogs = {};
13
+ const entries: string[] = [];
14
+ try {
15
+ const glob = new Bun.Glob("*.json");
16
+ for await (const rel of glob.scan({ cwd: dir, onlyFiles: true })) {
17
+ entries.push(rel);
18
+ }
19
+ } catch {
20
+ return out; // no directory — nothing to load
21
+ }
22
+ for (const rel of entries) {
23
+ const locale = rel
24
+ .replace(/\.json$/, "")
25
+ .replace(/\\/g, "/")
26
+ .split("/")
27
+ .pop()!;
28
+ try {
29
+ out[locale] = (await Bun.file(`${dir}/${rel}`).json()) as Messages;
30
+ } catch (err) {
31
+ throw new CatalogLoadError(`${dir}/${rel}`, err);
32
+ }
33
+ }
34
+ return out;
35
+ }
package/src/locale.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { I18nConfigShape } from "./config.ts";
2
+
3
+ /** Parse an `Accept-Language` header into tags ordered by descending quality. */
4
+ export function parseAcceptLanguage(header: string | null): string[] {
5
+ if (!header) return [];
6
+ return header
7
+ .split(",")
8
+ .map((part) => {
9
+ const [tag, q] = part.trim().split(";q=");
10
+ return { tag: (tag ?? "").trim().toLowerCase(), q: q ? parseFloat(q) : 1 };
11
+ })
12
+ .filter((x) => x.tag.length > 0)
13
+ .sort((a, b) => b.q - a.q)
14
+ .map((x) => x.tag);
15
+ }
16
+
17
+ /** Read a single cookie value from a Cookie header. */
18
+ function _cookie(header: string | null, name: string): string | null {
19
+ if (!header) return null;
20
+ for (const pair of header.split(";")) {
21
+ const eq = pair.indexOf("=");
22
+ if (eq === -1) continue;
23
+ if (pair.slice(0, eq).trim() === name) {
24
+ return decodeURIComponent(pair.slice(eq + 1).trim());
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+
30
+ /**
31
+ * Resolve the locale for a request by trying each configured resolver in order.
32
+ * Only returns a locale present in `supportedLocales`; otherwise `defaultLocale`.
33
+ */
34
+ export function resolveLocale(request: Request, config: I18nConfigShape): string {
35
+ const supported = config.supportedLocales;
36
+ const ok = (l: string | null | undefined): l is string => !!l && supported.includes(l);
37
+
38
+ for (const resolver of config.resolvers) {
39
+ if (resolver === "query") {
40
+ const v = new URL(request.url).searchParams.get(config.queryKey);
41
+ if (ok(v)) return v;
42
+ } else if (resolver === "cookie") {
43
+ const v = _cookie(request.headers.get("cookie"), config.cookieKey);
44
+ if (ok(v)) return v;
45
+ } else if (resolver === "accept-header") {
46
+ for (const tag of parseAcceptLanguage(request.headers.get("accept-language"))) {
47
+ if (supported.includes(tag)) return tag;
48
+ const base = tag.split("-")[0]!; // 'en-US' → 'en'
49
+ if (ok(base)) return base;
50
+ }
51
+ }
52
+ }
53
+ return config.defaultLocale;
54
+ }
@@ -0,0 +1,54 @@
1
+ import { ServiceProvider } from "@zerotal/core";
2
+ import type { AppEnvironment } from "@zerotal/core";
3
+ import { Translator } from "../Translator.ts";
4
+ import { loadCatalogs } from "../loadCatalogs.ts";
5
+ import { LocaleMiddleware } from "../LocaleMiddleware.ts";
6
+ import { I18nConfig } from "../config.ts";
7
+ import type { I18nConfigShape } from "../config.ts";
8
+ import "../augment.ts";
9
+
10
+ /**
11
+ * Registers the translation service and wires request-locale resolution.
12
+ *
13
+ * @example
14
+ * // bootstrap/providers.ts
15
+ * import { I18nProvider } from '@zerotal/i18n';
16
+ * export default [I18nProvider];
17
+ *
18
+ * // config/i18n.ts
19
+ * import { I18nConfig } from '@zerotal/i18n';
20
+ * export default I18nConfig({ supportedLocales: ['en', 'fr'], loadPath: 'resources/lang' });
21
+ */
22
+ export class I18nProvider extends ServiceProvider {
23
+ static override provides = ["i18n"] as const;
24
+ static override environments: AppEnvironment[] = ["web", "console", "worker", "test", "repl"];
25
+
26
+ private async _resolveConfig(): Promise<I18nConfigShape> {
27
+ const cfg = (await this.app.container.make("config")) as {
28
+ get<T>(path: string): T | undefined;
29
+ };
30
+ return I18nConfig(cfg.get<Partial<I18nConfigShape>>("i18n") ?? {});
31
+ }
32
+
33
+ override onRegister(): void {
34
+ this.app.container.singleton("i18n", async () => {
35
+ const config = await this._resolveConfig();
36
+ const catalogs = {
37
+ ...(config.loadPath ? await loadCatalogs(config.loadPath) : {}),
38
+ ...(config.catalogs ?? {}),
39
+ };
40
+ return new Translator({
41
+ catalogs,
42
+ defaultLocale: config.defaultLocale,
43
+ fallbackLocale: config.fallbackLocale,
44
+ });
45
+ });
46
+ }
47
+
48
+ override async onBooting(): Promise<void> {
49
+ const translator = (await this.app.container.make("i18n")) as Translator;
50
+ const config = await this._resolveConfig();
51
+ LocaleMiddleware.configure(translator, config);
52
+ this.app.useOnce(LocaleMiddleware as never);
53
+ }
54
+ }
package/src/types.ts ADDED
@@ -0,0 +1,11 @@
1
+ /** A translation catalog — nested objects and/or flat dotted keys, leaves are strings. */
2
+ export type Messages = { [key: string]: string | Messages };
3
+
4
+ /** locale → messages */
5
+ export type Catalogs = Record<string, Messages>;
6
+
7
+ /** Interpolation values; `count` additionally drives pluralization. */
8
+ export type Replacements = Record<string, string | number>;
9
+
10
+ /** Built-in request locale resolvers, applied in order. */
11
+ export type LocaleResolver = "query" | "cookie" | "accept-header";