@yaebal/i18n 0.0.1
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/LICENSE +21 -0
- package/README.md +15 -0
- package/lib/index.d.ts +44 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +41 -0
- package/lib/index.js.map +1 -0
- package/lib/index.test.d.ts +2 -0
- package/lib/index.test.d.ts.map +1 -0
- package/lib/index.test.js +154 -0
- package/lib/index.test.js.map +1 -0
- package/package.json +49 -0
- package/src/index.test.ts +199 -0
- package/src/index.ts +91 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 neverlane
|
|
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,15 @@
|
|
|
1
|
+
# @yaebal/i18n
|
|
2
|
+
|
|
3
|
+
a plural form set keyed by `Intl.PluralRules` categories. `other` is required
|
|
4
|
+
as the fallback; the rest are optional and selected per locale via
|
|
5
|
+
`new Intl.PluralRules(locale).select(n)`.
|
|
6
|
+
|
|
7
|
+
## install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm add @yaebal/i18n
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
part of [**yaebal**](https://github.com/neverlane/yaebal) — a type-safe, runtime-agnostic Telegram Bot API framework. MIT.
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Context, Plugin } from "@yaebal/core";
|
|
2
|
+
import { type StorageAdapter } from "@yaebal/session";
|
|
3
|
+
/**
|
|
4
|
+
* a plural form set keyed by `Intl.PluralRules` categories. `other` is required
|
|
5
|
+
* as the fallback; the rest are optional and selected per locale via
|
|
6
|
+
* `new Intl.PluralRules(locale).select(n)`.
|
|
7
|
+
*/
|
|
8
|
+
export interface PluralDict {
|
|
9
|
+
zero?: string;
|
|
10
|
+
one?: string;
|
|
11
|
+
two?: string;
|
|
12
|
+
few?: string;
|
|
13
|
+
many?: string;
|
|
14
|
+
other: string;
|
|
15
|
+
}
|
|
16
|
+
/** a single translation value: a plain template or a set of plural forms. */
|
|
17
|
+
export type DictValue = string | PluralDict;
|
|
18
|
+
/**
|
|
19
|
+
* a translation table: key → template (with `{placeholder}` interpolation), or
|
|
20
|
+
* key → plural forms (selected by `{n}` via `Intl.PluralRules`).
|
|
21
|
+
*/
|
|
22
|
+
export type Dict = Record<string, DictValue>;
|
|
23
|
+
export type TFn = (key: string, params?: Record<string, unknown>) => string;
|
|
24
|
+
/** what the plugin adds to the context. powers morda/jsx `useTranslation`. */
|
|
25
|
+
export interface I18nControls {
|
|
26
|
+
t: TFn;
|
|
27
|
+
locale: string;
|
|
28
|
+
changeLanguage(locale: string): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export interface I18nOptions<L extends string> {
|
|
31
|
+
defaultLocale: L;
|
|
32
|
+
locales: Record<L, Dict>;
|
|
33
|
+
/** where to persist each chat's locale. defaults to in-memory. */
|
|
34
|
+
storage?: StorageAdapter<string>;
|
|
35
|
+
/** locale key for an update. default: per-chat (`ctx.chat.id`). */
|
|
36
|
+
getKey?: (ctx: Context) => string | undefined;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* i18n plugin. adds `ctx.t` / `ctx.locale` / `ctx.changeLanguage`, with the
|
|
40
|
+
* active locale persisted per chat. missing keys fall back to the default
|
|
41
|
+
* locale, then to the key itself.
|
|
42
|
+
*/
|
|
43
|
+
export declare function i18n<L extends string>(options: I18nOptions<L>): Plugin<Context, I18nControls>;
|
|
44
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACpD,OAAO,EAAiB,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAErE;;;;GAIG;AACH,MAAM,WAAW,UAAU;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACd;AAED,6EAA6E;AAC7E,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,UAAU,CAAC;AAE5C;;;GAGG;AACH,MAAM,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;AAE7C,MAAM,MAAM,GAAG,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,CAAC;AAE5E,8EAA8E;AAC9E,MAAM,WAAW,YAAY;IAC5B,CAAC,EAAE,GAAG,CAAC;IACP,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9C;AAED,MAAM,WAAW,WAAW,CAAC,CAAC,SAAS,MAAM;IAC5C,aAAa,EAAE,CAAC,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;IACzB,kEAAkE;IAClE,OAAO,CAAC,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACjC,mEAAmE;IACnE,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAC;CAC9C;AAED;;;;GAIG;AACH,wBAAgB,IAAI,CAAC,CAAC,SAAS,MAAM,EAAE,OAAO,EAAE,WAAW,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,OAAO,EAAE,YAAY,CAAC,CAyC7F"}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { MemoryStorage } from "@yaebal/session";
|
|
2
|
+
/**
|
|
3
|
+
* i18n plugin. adds `ctx.t` / `ctx.locale` / `ctx.changeLanguage`, with the
|
|
4
|
+
* active locale persisted per chat. missing keys fall back to the default
|
|
5
|
+
* locale, then to the key itself.
|
|
6
|
+
*/
|
|
7
|
+
export function i18n(options) {
|
|
8
|
+
const { defaultLocale, locales } = options;
|
|
9
|
+
const storage = options.storage ?? new MemoryStorage();
|
|
10
|
+
const getKey = options.getKey ?? ((ctx) => ctx.chat?.id?.toString());
|
|
11
|
+
return (composer) => composer.derive(async (ctx) => {
|
|
12
|
+
const key = getKey(ctx);
|
|
13
|
+
let locale = (key !== undefined ? await storage.get(key) : undefined) ?? defaultLocale;
|
|
14
|
+
const t = (k, params) => {
|
|
15
|
+
const def = locales[defaultLocale] ?? {};
|
|
16
|
+
const dict = locales[locale] ?? def;
|
|
17
|
+
const raw = dict[k] ?? def[k] ?? k;
|
|
18
|
+
let s;
|
|
19
|
+
if (typeof raw === "string") {
|
|
20
|
+
s = raw;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
const n = params?.n;
|
|
24
|
+
const category = typeof n === "number" ? new Intl.PluralRules(locale).select(n) : "other";
|
|
25
|
+
s = raw[category] ?? raw.other;
|
|
26
|
+
}
|
|
27
|
+
if (params) {
|
|
28
|
+
for (const [pk, pv] of Object.entries(params))
|
|
29
|
+
s = s.replaceAll(`{${pk}}`, String(pv));
|
|
30
|
+
}
|
|
31
|
+
return s;
|
|
32
|
+
};
|
|
33
|
+
const changeLanguage = async (next) => {
|
|
34
|
+
locale = next;
|
|
35
|
+
if (key !== undefined)
|
|
36
|
+
await storage.set(key, next);
|
|
37
|
+
};
|
|
38
|
+
return { t, locale, changeLanguage };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=index.js.map
|
package/lib/index.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAuB,MAAM,iBAAiB,CAAC;AA2CrE;;;;GAIG;AACH,MAAM,UAAU,IAAI,CAAmB,OAAuB;IAC7D,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;IAE3C,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,IAAI,aAAa,EAAU,CAAC;IAC/D,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC,GAAY,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC,CAAC;IAE9E,OAAO,CAAC,QAAQ,EAAE,EAAE,CACnB,QAAQ,CAAC,MAAM,CAAC,KAAK,EAAE,GAAG,EAAyB,EAAE;QACpD,MAAM,GAAG,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;QACxB,IAAI,MAAM,GACT,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,MAAM,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,IAAI,aAAa,CAAC;QAE3E,MAAM,CAAC,GAAQ,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE;YAC5B,MAAM,GAAG,GAAS,OAAO,CAAC,aAAa,CAAC,IAAI,EAAE,CAAC;YAC/C,MAAM,IAAI,GAAS,OAAO,CAAC,MAAW,CAAC,IAAI,GAAG,CAAC;YAC/C,MAAM,GAAG,GAAc,IAAI,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;YAE9C,IAAI,CAAS,CAAC;YACd,IAAI,OAAO,GAAG,KAAK,QAAQ,EAAE,CAAC;gBAC7B,CAAC,GAAG,GAAG,CAAC;YACT,CAAC;iBAAM,CAAC;gBACP,MAAM,CAAC,GAAG,MAAM,EAAE,CAAC,CAAC;gBACpB,MAAM,QAAQ,GAAG,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;gBAE1F,CAAC,GAAG,GAAG,CAAC,QAA4B,CAAC,IAAI,GAAG,CAAC,KAAK,CAAC;YACpD,CAAC;YAED,IAAI,MAAM,EAAE,CAAC;gBACZ,KAAK,MAAM,CAAC,EAAE,EAAE,EAAE,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC;oBAAE,CAAC,GAAG,CAAC,CAAC,UAAU,CAAC,IAAI,EAAE,GAAG,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC;YACxF,CAAC;YAED,OAAO,CAAC,CAAC;QACV,CAAC,CAAC;QAEF,MAAM,cAAc,GAAG,KAAK,EAAE,IAAY,EAAiB,EAAE;YAC5D,MAAM,GAAG,IAAI,CAAC;YACd,IAAI,GAAG,KAAK,SAAS;gBAAE,MAAM,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;QACrD,CAAC,CAAC;QAEF,OAAO,EAAE,CAAC,EAAE,MAAM,EAAE,cAAc,EAAE,CAAC;IACtC,CAAC,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { Composer, Context } from "@yaebal/core";
|
|
4
|
+
import { MemoryStorage } from "@yaebal/session";
|
|
5
|
+
import { i18n } from "./index.js";
|
|
6
|
+
const noop = async () => { };
|
|
7
|
+
const entry = (c) => c.toMiddleware();
|
|
8
|
+
const api = {};
|
|
9
|
+
const ctxFor = (chatId) => new Context({
|
|
10
|
+
api,
|
|
11
|
+
update: {
|
|
12
|
+
update_id: 1,
|
|
13
|
+
message: {
|
|
14
|
+
message_id: 1,
|
|
15
|
+
date: 0,
|
|
16
|
+
chat: { id: chatId, type: "private" },
|
|
17
|
+
from: { id: chatId, is_bot: false, first_name: "u" },
|
|
18
|
+
text: "hi",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
updateType: "message",
|
|
22
|
+
});
|
|
23
|
+
const locales = {
|
|
24
|
+
en: {
|
|
25
|
+
hi: "Hello {name}",
|
|
26
|
+
bye: "Bye",
|
|
27
|
+
items: { one: "{n} item", other: "{n} items" },
|
|
28
|
+
},
|
|
29
|
+
ru: {
|
|
30
|
+
hi: "Привет {name}",
|
|
31
|
+
items: {
|
|
32
|
+
one: "{n} предмет",
|
|
33
|
+
few: "{n} предмета",
|
|
34
|
+
many: "{n} предметов",
|
|
35
|
+
other: "{n} предмета",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
test("t interpolates params and falls back to the key", async () => {
|
|
40
|
+
let hi = "";
|
|
41
|
+
let missing = "";
|
|
42
|
+
const c = new Composer()
|
|
43
|
+
.install(i18n({ defaultLocale: "en", locales }))
|
|
44
|
+
.use((ctx, next) => {
|
|
45
|
+
hi = ctx.t("hi", { name: "Sam" });
|
|
46
|
+
missing = ctx.t("nope");
|
|
47
|
+
return next();
|
|
48
|
+
});
|
|
49
|
+
await entry(c)(ctxFor(1), noop);
|
|
50
|
+
assert.equal(hi, "Hello Sam");
|
|
51
|
+
assert.equal(missing, "nope");
|
|
52
|
+
});
|
|
53
|
+
test("changeLanguage switches the active locale within the handler", async () => {
|
|
54
|
+
let after = "";
|
|
55
|
+
const c = new Composer()
|
|
56
|
+
.install(i18n({ defaultLocale: "en", locales }))
|
|
57
|
+
.use(async (ctx, next) => {
|
|
58
|
+
await ctx.changeLanguage("ru");
|
|
59
|
+
after = ctx.t("hi", { name: "Х" });
|
|
60
|
+
return next();
|
|
61
|
+
});
|
|
62
|
+
await entry(c)(ctxFor(2), noop);
|
|
63
|
+
assert.equal(after, "Привет Х");
|
|
64
|
+
});
|
|
65
|
+
test("locale persists per chat across updates", async () => {
|
|
66
|
+
const storage = new MemoryStorage();
|
|
67
|
+
let seen = "";
|
|
68
|
+
const c = new Composer()
|
|
69
|
+
.install(i18n({ defaultLocale: "en", locales, storage }))
|
|
70
|
+
.use(async (ctx, next) => {
|
|
71
|
+
const x = ctx;
|
|
72
|
+
if (x.t("hi", { name: "a" }) === "Hello a")
|
|
73
|
+
await x.changeLanguage("ru");
|
|
74
|
+
seen = x.locale;
|
|
75
|
+
return next();
|
|
76
|
+
});
|
|
77
|
+
const mw = entry(c);
|
|
78
|
+
await mw(ctxFor(3), noop); // en → switches to ru
|
|
79
|
+
await mw(ctxFor(3), noop); // loads ru
|
|
80
|
+
assert.equal(seen, "ru");
|
|
81
|
+
});
|
|
82
|
+
test("plural selection follows the locale's Intl.PluralRules (en)", async () => {
|
|
83
|
+
let one = "";
|
|
84
|
+
let other = "";
|
|
85
|
+
const c = new Composer()
|
|
86
|
+
.install(i18n({ defaultLocale: "en", locales }))
|
|
87
|
+
.use((ctx, next) => {
|
|
88
|
+
const x = ctx;
|
|
89
|
+
one = x.t("items", { n: 1 });
|
|
90
|
+
other = x.t("items", { n: 2 });
|
|
91
|
+
return next();
|
|
92
|
+
});
|
|
93
|
+
await entry(c)(ctxFor(10), noop);
|
|
94
|
+
assert.equal(one, "1 item"); // en: 1 → one
|
|
95
|
+
assert.equal(other, "2 items"); // en: 2 → other
|
|
96
|
+
});
|
|
97
|
+
test("plural selection follows the locale's Intl.PluralRules (ru)", async () => {
|
|
98
|
+
let one = "";
|
|
99
|
+
let few = "";
|
|
100
|
+
let many = "";
|
|
101
|
+
const c = new Composer()
|
|
102
|
+
.install(i18n({ defaultLocale: "en", locales }))
|
|
103
|
+
.use(async (ctx, next) => {
|
|
104
|
+
const x = ctx;
|
|
105
|
+
await x.changeLanguage("ru");
|
|
106
|
+
one = x.t("items", { n: 1 });
|
|
107
|
+
few = x.t("items", { n: 2 });
|
|
108
|
+
many = x.t("items", { n: 5 });
|
|
109
|
+
return next();
|
|
110
|
+
});
|
|
111
|
+
await entry(c)(ctxFor(11), noop);
|
|
112
|
+
assert.equal(one, "1 предмет"); // ru: 1 → one
|
|
113
|
+
assert.equal(few, "2 предмета"); // ru: 2 → few
|
|
114
|
+
assert.equal(many, "5 предметов"); // ru: 5 → many
|
|
115
|
+
});
|
|
116
|
+
test("plain-string keys keep working alongside plural objects (regression)", async () => {
|
|
117
|
+
let hi = "";
|
|
118
|
+
let bye = "";
|
|
119
|
+
const c = new Composer()
|
|
120
|
+
.install(i18n({ defaultLocale: "en", locales }))
|
|
121
|
+
.use((ctx, next) => {
|
|
122
|
+
const x = ctx;
|
|
123
|
+
hi = x.t("hi", { name: "Sam" }); // interpolated plain string
|
|
124
|
+
bye = x.t("bye"); // bare plain string
|
|
125
|
+
return next();
|
|
126
|
+
});
|
|
127
|
+
await entry(c)(ctxFor(12), noop);
|
|
128
|
+
assert.equal(hi, "Hello Sam");
|
|
129
|
+
assert.equal(bye, "Bye");
|
|
130
|
+
});
|
|
131
|
+
test("interpolation works inside a chosen plural form", async () => {
|
|
132
|
+
let s = "";
|
|
133
|
+
const c = new Composer()
|
|
134
|
+
.install(i18n({ defaultLocale: "en", locales }))
|
|
135
|
+
.use((ctx, next) => {
|
|
136
|
+
s = ctx.t("items", { n: 42 });
|
|
137
|
+
return next();
|
|
138
|
+
});
|
|
139
|
+
await entry(c)(ctxFor(13), noop);
|
|
140
|
+
assert.equal(s, "42 items"); // 42 → other, {n} interpolated
|
|
141
|
+
});
|
|
142
|
+
test("missing key in a locale falls back to the default locale", async () => {
|
|
143
|
+
let bye = "";
|
|
144
|
+
const c = new Composer()
|
|
145
|
+
.install(i18n({ defaultLocale: "en", locales }))
|
|
146
|
+
.use(async (ctx, next) => {
|
|
147
|
+
await ctx.changeLanguage("ru"); // ru has no "bye"
|
|
148
|
+
bye = ctx.t("bye");
|
|
149
|
+
return next();
|
|
150
|
+
});
|
|
151
|
+
await entry(c)(ctxFor(4), noop);
|
|
152
|
+
assert.equal(bye, "Bye"); // fell back to en
|
|
153
|
+
});
|
|
154
|
+
//# sourceMappingURL=index.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.test.js","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,oBAAoB,CAAC;AACxC,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAmB,MAAM,cAAc,CAAC;AAClE,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EAAqB,IAAI,EAAE,MAAM,YAAY,CAAC;AAErD,MAAM,IAAI,GAAG,KAAK,IAAI,EAAE,GAAE,CAAC,CAAC;AAC5B,MAAM,KAAK,GAAG,CAAoB,CAAc,EAAE,EAAE,CACnD,CAAC,CAAC,YAAY,EAAoC,CAAC;AAEpD,MAAM,GAAG,GAAG,EAAW,CAAC;AACxB,MAAM,MAAM,GAAG,CAAC,MAAc,EAAE,EAAE,CACjC,IAAI,OAAO,CAAC;IACX,GAAG;IACH,MAAM,EAAE;QACP,SAAS,EAAE,CAAC;QACZ,OAAO,EAAE;YACR,UAAU,EAAE,CAAC;YACb,IAAI,EAAE,CAAC;YACP,IAAI,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE;YACrC,IAAI,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,GAAG,EAAE;YACpD,IAAI,EAAE,IAAI;SACV;KACQ;IACV,UAAU,EAAE,SAAS;CACrB,CAAC,CAAC;AAEJ,MAAM,OAAO,GAAG;IACf,EAAE,EAAE;QACH,EAAE,EAAE,cAAc;QAClB,GAAG,EAAE,KAAK;QACV,KAAK,EAAE,EAAE,GAAG,EAAE,UAAU,EAAE,KAAK,EAAE,WAAW,EAAE;KAC9C;IACD,EAAE,EAAE;QACH,EAAE,EAAE,eAAe;QACnB,KAAK,EAAE;YACN,GAAG,EAAE,aAAa;YAClB,GAAG,EAAE,cAAc;YACnB,IAAI,EAAE,eAAe;YACrB,KAAK,EAAE,cAAc;SACrB;KACD;CACD,CAAC;AAIF,IAAI,CAAC,iDAAiD,EAAE,KAAK,IAAI,EAAE;IAClE,IAAI,EAAE,GAAG,EAAE,CAAC;IACZ,IAAI,OAAO,GAAG,EAAE,CAAC;IAEjB,MAAM,CAAC,GAAG,IAAI,QAAQ,EAAW;SAC/B,OAAO,CAAC,IAAI,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;SAC/C,GAAG,CAAC,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE;QAClB,EAAE,GAAI,GAAW,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;QAC3C,OAAO,GAAI,GAAW,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;QAEjC,OAAO,IAAI,EAAE,CAAC;IACf,CAAC,CAAC,CAAC;IAEJ,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;IAEhC,MAAM,CAAC,KAAK,CAAC,EAAE,EAAE,WAAW,CAAC,CAAC;IAC9B,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;AAC/B,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,8DAA8D,EAAE,KAAK,IAAI,EAAE;IAC/E,IAAI,KAAK,GAAG,EAAE,CAAC;IAEf,MAAM,CAAC,GAAG,IAAI,QAAQ,EAAW;SAC/B,OAAO,CAAC,IAAI,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;SAC/C,GAAG,CAAC,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QACxB,MAAO,GAAW,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC;QAExC,KAAK,GAAI,GAAW,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC;QAC5C,OAAO,IAAI,EAAE,CAAC;IACf,CAAC,CAAC,CAAC;IAEJ,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;IAEhC,MAAM,CAAC,KAAK,CAAC,KAAK,EAAE,UAAU,CAAC,CAAC;AACjC,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,yCAAyC,EAAE,KAAK,IAAI,EAAE;IAC1D,MAAM,OAAO,GAAG,IAAI,aAAa,EAAU,CAAC;IAE5C,IAAI,IAAI,GAAG,EAAE,CAAC;IAEd,MAAM,CAAC,GAAG,IAAI,QAAQ,EAAW;SAC/B,OAAO,CAAC,IAAI,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,CAAC;SACxD,GAAG,CAAC,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QACxB,MAAM,CAAC,GAAG,GAAU,CAAC;QAErB,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,KAAK,SAAS;YAAE,MAAM,CAAC,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC;QAEzE,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC;QAChB,OAAO,IAAI,EAAE,CAAC;IACf,CAAC,CAAC,CAAC;IAEJ,MAAM,EAAE,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;IACpB,MAAM,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,sBAAsB;IACjD,MAAM,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,WAAW;IAEtC,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;AAC1B,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,6DAA6D,EAAE,KAAK,IAAI,EAAE;IAC9E,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,IAAI,KAAK,GAAG,EAAE,CAAC;IAEf,MAAM,CAAC,GAAG,IAAI,QAAQ,EAAW;SAC/B,OAAO,CAAC,IAAI,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;SAC/C,GAAG,CAAC,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE;QAClB,MAAM,CAAC,GAAG,GAAU,CAAC;QAErB,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;QAC7B,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;QAE/B,OAAO,IAAI,EAAE,CAAC;IACf,CAAC,CAAC,CAAC;IAEJ,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC;IACjC,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC,CAAC,cAAc;IAC3C,MAAM,CAAC,KAAK,CAAC,KAAK,EAAE,SAAS,CAAC,CAAC,CAAC,gBAAgB;AACjD,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,6DAA6D,EAAE,KAAK,IAAI,EAAE;IAC9E,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,IAAI,IAAI,GAAG,EAAE,CAAC;IACd,MAAM,CAAC,GAAG,IAAI,QAAQ,EAAW;SAC/B,OAAO,CAAC,IAAI,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;SAC/C,GAAG,CAAC,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QACxB,MAAM,CAAC,GAAG,GAAU,CAAC;QACrB,MAAM,CAAC,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC;QAE7B,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;QAC7B,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;QAC7B,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;QAE9B,OAAO,IAAI,EAAE,CAAC;IACf,CAAC,CAAC,CAAC;IAEJ,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC;IAEjC,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,WAAW,CAAC,CAAC,CAAC,cAAc;IAC9C,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,YAAY,CAAC,CAAC,CAAC,cAAc;IAC/C,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,aAAa,CAAC,CAAC,CAAC,eAAe;AACnD,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,sEAAsE,EAAE,KAAK,IAAI,EAAE;IACvF,IAAI,EAAE,GAAG,EAAE,CAAC;IACZ,IAAI,GAAG,GAAG,EAAE,CAAC;IAEb,MAAM,CAAC,GAAG,IAAI,QAAQ,EAAW;SAC/B,OAAO,CAAC,IAAI,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;SAC/C,GAAG,CAAC,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE;QAClB,MAAM,CAAC,GAAG,GAAU,CAAC;QAErB,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,4BAA4B;QAC7D,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,oBAAoB;QAEtC,OAAO,IAAI,EAAE,CAAC;IACf,CAAC,CAAC,CAAC;IAEJ,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC;IACjC,MAAM,CAAC,KAAK,CAAC,EAAE,EAAE,WAAW,CAAC,CAAC;IAC9B,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;AAC1B,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,iDAAiD,EAAE,KAAK,IAAI,EAAE;IAClE,IAAI,CAAC,GAAG,EAAE,CAAC;IAEX,MAAM,CAAC,GAAG,IAAI,QAAQ,EAAW;SAC/B,OAAO,CAAC,IAAI,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;SAC/C,GAAG,CAAC,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE;QAClB,CAAC,GAAI,GAAW,CAAC,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;QAEvC,OAAO,IAAI,EAAE,CAAC;IACf,CAAC,CAAC,CAAC;IAEJ,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC;IACjC,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,+BAA+B;AAC7D,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,0DAA0D,EAAE,KAAK,IAAI,EAAE;IAC3E,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,MAAM,CAAC,GAAG,IAAI,QAAQ,EAAW;SAC/B,OAAO,CAAC,IAAI,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;SAC/C,GAAG,CAAC,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QACxB,MAAO,GAAW,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC,kBAAkB;QAE3D,GAAG,GAAI,GAAW,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;QAE5B,OAAO,IAAI,EAAE,CAAC;IACf,CAAC,CAAC,CAAC;IAEJ,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;IAChC,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC,kBAAkB;AAC7C,CAAC,CAAC,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yaebal/i18n",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "yaebal i18n plugin — per-chat locale, ctx.t / ctx.changeLanguage (powers useTranslation).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./lib/index.js",
|
|
7
|
+
"types": "./lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"import": "./lib/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"lib",
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@yaebal/core": "0.0.1",
|
|
20
|
+
"@yaebal/session": "0.0.1"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "latest"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"telegram",
|
|
30
|
+
"telegram-bot",
|
|
31
|
+
"yaebal",
|
|
32
|
+
"i18n",
|
|
33
|
+
"localization"
|
|
34
|
+
],
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "https://github.com/neverlane/yaebal",
|
|
39
|
+
"directory": "packages/i18n"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsc -p tsconfig.json",
|
|
46
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
47
|
+
"test": "node --test lib"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { Composer, Context, type Middleware } from "@yaebal/core";
|
|
4
|
+
import { MemoryStorage } from "@yaebal/session";
|
|
5
|
+
import { type I18nControls, i18n } from "./index.js";
|
|
6
|
+
|
|
7
|
+
const noop = async () => {};
|
|
8
|
+
const entry = <C extends Context>(c: Composer<C>) =>
|
|
9
|
+
c.toMiddleware() as unknown as Middleware<Context>;
|
|
10
|
+
|
|
11
|
+
const api = {} as never;
|
|
12
|
+
const ctxFor = (chatId: number) =>
|
|
13
|
+
new Context({
|
|
14
|
+
api,
|
|
15
|
+
update: {
|
|
16
|
+
update_id: 1,
|
|
17
|
+
message: {
|
|
18
|
+
message_id: 1,
|
|
19
|
+
date: 0,
|
|
20
|
+
chat: { id: chatId, type: "private" },
|
|
21
|
+
from: { id: chatId, is_bot: false, first_name: "u" },
|
|
22
|
+
text: "hi",
|
|
23
|
+
},
|
|
24
|
+
} as never,
|
|
25
|
+
updateType: "message",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const locales = {
|
|
29
|
+
en: {
|
|
30
|
+
hi: "Hello {name}",
|
|
31
|
+
bye: "Bye",
|
|
32
|
+
items: { one: "{n} item", other: "{n} items" },
|
|
33
|
+
},
|
|
34
|
+
ru: {
|
|
35
|
+
hi: "Привет {name}",
|
|
36
|
+
items: {
|
|
37
|
+
one: "{n} предмет",
|
|
38
|
+
few: "{n} предмета",
|
|
39
|
+
many: "{n} предметов",
|
|
40
|
+
other: "{n} предмета",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type Ctx = Context & I18nControls;
|
|
46
|
+
|
|
47
|
+
test("t interpolates params and falls back to the key", async () => {
|
|
48
|
+
let hi = "";
|
|
49
|
+
let missing = "";
|
|
50
|
+
|
|
51
|
+
const c = new Composer<Context>()
|
|
52
|
+
.install(i18n({ defaultLocale: "en", locales }))
|
|
53
|
+
.use((ctx, next) => {
|
|
54
|
+
hi = (ctx as Ctx).t("hi", { name: "Sam" });
|
|
55
|
+
missing = (ctx as Ctx).t("nope");
|
|
56
|
+
|
|
57
|
+
return next();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await entry(c)(ctxFor(1), noop);
|
|
61
|
+
|
|
62
|
+
assert.equal(hi, "Hello Sam");
|
|
63
|
+
assert.equal(missing, "nope");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("changeLanguage switches the active locale within the handler", async () => {
|
|
67
|
+
let after = "";
|
|
68
|
+
|
|
69
|
+
const c = new Composer<Context>()
|
|
70
|
+
.install(i18n({ defaultLocale: "en", locales }))
|
|
71
|
+
.use(async (ctx, next) => {
|
|
72
|
+
await (ctx as Ctx).changeLanguage("ru");
|
|
73
|
+
|
|
74
|
+
after = (ctx as Ctx).t("hi", { name: "Х" });
|
|
75
|
+
return next();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await entry(c)(ctxFor(2), noop);
|
|
79
|
+
|
|
80
|
+
assert.equal(after, "Привет Х");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("locale persists per chat across updates", async () => {
|
|
84
|
+
const storage = new MemoryStorage<string>();
|
|
85
|
+
|
|
86
|
+
let seen = "";
|
|
87
|
+
|
|
88
|
+
const c = new Composer<Context>()
|
|
89
|
+
.install(i18n({ defaultLocale: "en", locales, storage }))
|
|
90
|
+
.use(async (ctx, next) => {
|
|
91
|
+
const x = ctx as Ctx;
|
|
92
|
+
|
|
93
|
+
if (x.t("hi", { name: "a" }) === "Hello a") await x.changeLanguage("ru");
|
|
94
|
+
|
|
95
|
+
seen = x.locale;
|
|
96
|
+
return next();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const mw = entry(c);
|
|
100
|
+
await mw(ctxFor(3), noop); // en → switches to ru
|
|
101
|
+
await mw(ctxFor(3), noop); // loads ru
|
|
102
|
+
|
|
103
|
+
assert.equal(seen, "ru");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("plural selection follows the locale's Intl.PluralRules (en)", async () => {
|
|
107
|
+
let one = "";
|
|
108
|
+
let other = "";
|
|
109
|
+
|
|
110
|
+
const c = new Composer<Context>()
|
|
111
|
+
.install(i18n({ defaultLocale: "en", locales }))
|
|
112
|
+
.use((ctx, next) => {
|
|
113
|
+
const x = ctx as Ctx;
|
|
114
|
+
|
|
115
|
+
one = x.t("items", { n: 1 });
|
|
116
|
+
other = x.t("items", { n: 2 });
|
|
117
|
+
|
|
118
|
+
return next();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await entry(c)(ctxFor(10), noop);
|
|
122
|
+
assert.equal(one, "1 item"); // en: 1 → one
|
|
123
|
+
assert.equal(other, "2 items"); // en: 2 → other
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("plural selection follows the locale's Intl.PluralRules (ru)", async () => {
|
|
127
|
+
let one = "";
|
|
128
|
+
let few = "";
|
|
129
|
+
let many = "";
|
|
130
|
+
const c = new Composer<Context>()
|
|
131
|
+
.install(i18n({ defaultLocale: "en", locales }))
|
|
132
|
+
.use(async (ctx, next) => {
|
|
133
|
+
const x = ctx as Ctx;
|
|
134
|
+
await x.changeLanguage("ru");
|
|
135
|
+
|
|
136
|
+
one = x.t("items", { n: 1 });
|
|
137
|
+
few = x.t("items", { n: 2 });
|
|
138
|
+
many = x.t("items", { n: 5 });
|
|
139
|
+
|
|
140
|
+
return next();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await entry(c)(ctxFor(11), noop);
|
|
144
|
+
|
|
145
|
+
assert.equal(one, "1 предмет"); // ru: 1 → one
|
|
146
|
+
assert.equal(few, "2 предмета"); // ru: 2 → few
|
|
147
|
+
assert.equal(many, "5 предметов"); // ru: 5 → many
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("plain-string keys keep working alongside plural objects (regression)", async () => {
|
|
151
|
+
let hi = "";
|
|
152
|
+
let bye = "";
|
|
153
|
+
|
|
154
|
+
const c = new Composer<Context>()
|
|
155
|
+
.install(i18n({ defaultLocale: "en", locales }))
|
|
156
|
+
.use((ctx, next) => {
|
|
157
|
+
const x = ctx as Ctx;
|
|
158
|
+
|
|
159
|
+
hi = x.t("hi", { name: "Sam" }); // interpolated plain string
|
|
160
|
+
bye = x.t("bye"); // bare plain string
|
|
161
|
+
|
|
162
|
+
return next();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await entry(c)(ctxFor(12), noop);
|
|
166
|
+
assert.equal(hi, "Hello Sam");
|
|
167
|
+
assert.equal(bye, "Bye");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("interpolation works inside a chosen plural form", async () => {
|
|
171
|
+
let s = "";
|
|
172
|
+
|
|
173
|
+
const c = new Composer<Context>()
|
|
174
|
+
.install(i18n({ defaultLocale: "en", locales }))
|
|
175
|
+
.use((ctx, next) => {
|
|
176
|
+
s = (ctx as Ctx).t("items", { n: 42 });
|
|
177
|
+
|
|
178
|
+
return next();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await entry(c)(ctxFor(13), noop);
|
|
182
|
+
assert.equal(s, "42 items"); // 42 → other, {n} interpolated
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("missing key in a locale falls back to the default locale", async () => {
|
|
186
|
+
let bye = "";
|
|
187
|
+
const c = new Composer<Context>()
|
|
188
|
+
.install(i18n({ defaultLocale: "en", locales }))
|
|
189
|
+
.use(async (ctx, next) => {
|
|
190
|
+
await (ctx as Ctx).changeLanguage("ru"); // ru has no "bye"
|
|
191
|
+
|
|
192
|
+
bye = (ctx as Ctx).t("bye");
|
|
193
|
+
|
|
194
|
+
return next();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await entry(c)(ctxFor(4), noop);
|
|
198
|
+
assert.equal(bye, "Bye"); // fell back to en
|
|
199
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Context, Plugin } from "@yaebal/core";
|
|
2
|
+
import { MemoryStorage, type StorageAdapter } from "@yaebal/session";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* a plural form set keyed by `Intl.PluralRules` categories. `other` is required
|
|
6
|
+
* as the fallback; the rest are optional and selected per locale via
|
|
7
|
+
* `new Intl.PluralRules(locale).select(n)`.
|
|
8
|
+
*/
|
|
9
|
+
export interface PluralDict {
|
|
10
|
+
zero?: string;
|
|
11
|
+
one?: string;
|
|
12
|
+
two?: string;
|
|
13
|
+
few?: string;
|
|
14
|
+
many?: string;
|
|
15
|
+
other: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** a single translation value: a plain template or a set of plural forms. */
|
|
19
|
+
export type DictValue = string | PluralDict;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* a translation table: key → template (with `{placeholder}` interpolation), or
|
|
23
|
+
* key → plural forms (selected by `{n}` via `Intl.PluralRules`).
|
|
24
|
+
*/
|
|
25
|
+
export type Dict = Record<string, DictValue>;
|
|
26
|
+
|
|
27
|
+
export type TFn = (key: string, params?: Record<string, unknown>) => string;
|
|
28
|
+
|
|
29
|
+
/** what the plugin adds to the context. powers morda/jsx `useTranslation`. */
|
|
30
|
+
export interface I18nControls {
|
|
31
|
+
t: TFn;
|
|
32
|
+
locale: string;
|
|
33
|
+
changeLanguage(locale: string): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface I18nOptions<L extends string> {
|
|
37
|
+
defaultLocale: L;
|
|
38
|
+
locales: Record<L, Dict>;
|
|
39
|
+
/** where to persist each chat's locale. defaults to in-memory. */
|
|
40
|
+
storage?: StorageAdapter<string>;
|
|
41
|
+
/** locale key for an update. default: per-chat (`ctx.chat.id`). */
|
|
42
|
+
getKey?: (ctx: Context) => string | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* i18n plugin. adds `ctx.t` / `ctx.locale` / `ctx.changeLanguage`, with the
|
|
47
|
+
* active locale persisted per chat. missing keys fall back to the default
|
|
48
|
+
* locale, then to the key itself.
|
|
49
|
+
*/
|
|
50
|
+
export function i18n<L extends string>(options: I18nOptions<L>): Plugin<Context, I18nControls> {
|
|
51
|
+
const { defaultLocale, locales } = options;
|
|
52
|
+
|
|
53
|
+
const storage = options.storage ?? new MemoryStorage<string>();
|
|
54
|
+
const getKey = options.getKey ?? ((ctx: Context) => ctx.chat?.id?.toString());
|
|
55
|
+
|
|
56
|
+
return (composer) =>
|
|
57
|
+
composer.derive(async (ctx): Promise<I18nControls> => {
|
|
58
|
+
const key = getKey(ctx);
|
|
59
|
+
let locale: string =
|
|
60
|
+
(key !== undefined ? await storage.get(key) : undefined) ?? defaultLocale;
|
|
61
|
+
|
|
62
|
+
const t: TFn = (k, params) => {
|
|
63
|
+
const def: Dict = locales[defaultLocale] ?? {};
|
|
64
|
+
const dict: Dict = locales[locale as L] ?? def;
|
|
65
|
+
const raw: DictValue = dict[k] ?? def[k] ?? k;
|
|
66
|
+
|
|
67
|
+
let s: string;
|
|
68
|
+
if (typeof raw === "string") {
|
|
69
|
+
s = raw;
|
|
70
|
+
} else {
|
|
71
|
+
const n = params?.n;
|
|
72
|
+
const category = typeof n === "number" ? new Intl.PluralRules(locale).select(n) : "other";
|
|
73
|
+
|
|
74
|
+
s = raw[category as keyof PluralDict] ?? raw.other;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (params) {
|
|
78
|
+
for (const [pk, pv] of Object.entries(params)) s = s.replaceAll(`{${pk}}`, String(pv));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return s;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const changeLanguage = async (next: string): Promise<void> => {
|
|
85
|
+
locale = next;
|
|
86
|
+
if (key !== undefined) await storage.set(key, next);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return { t, locale, changeLanguage };
|
|
90
|
+
});
|
|
91
|
+
}
|