@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 +27 -0
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/package.json +54 -0
- package/src/I18nContext.ts +20 -0
- package/src/LocaleMiddleware.ts +33 -0
- package/src/Translator.ts +107 -0
- package/src/augment.ts +22 -0
- package/src/config.ts +55 -0
- package/src/errors.ts +20 -0
- package/src/facades/Lang.ts +22 -0
- package/src/index.ts +32 -0
- package/src/loadCatalogs.ts +35 -0
- package/src/locale.ts +54 -0
- package/src/provider/I18nProvider.ts +54 -0
- package/src/types.ts +11 -0
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";
|