@zerotal/i18n 1.7.0 → 1.7.3
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/api-surface.md +2 -2
- package/package.json +2 -2
- package/src/LocaleMiddleware.ts +3 -3
- package/src/Translator.ts +24 -6
- package/src/augment.ts +19 -4
- package/src/facades/Lang.ts +11 -3
- package/src/index.ts +1 -1
- package/src/provider/I18nProvider.ts +11 -0
package/api-surface.md
CHANGED
|
@@ -64,6 +64,8 @@ class Translator = {
|
|
|
64
64
|
|
|
65
65
|
const Lang = Translator
|
|
66
66
|
|
|
67
|
+
function __ = (key: string, replacements?: Replacements, locale?: string) => string
|
|
68
|
+
|
|
67
69
|
function I18nConfig = (options?: Partial<I18nConfigShape>) => I18nConfigShape
|
|
68
70
|
|
|
69
71
|
function loadCatalogs = (dir: string) => Promise<Catalogs>
|
|
@@ -72,8 +74,6 @@ function parseAcceptLanguage = (header: string | null) => string[]
|
|
|
72
74
|
|
|
73
75
|
function resolveLocale = (request: Request, config: I18nConfigShape) => string
|
|
74
76
|
|
|
75
|
-
function t = (key: string, replacements?: Replacements, locale?: string) => string
|
|
76
|
-
|
|
77
77
|
interface I18nConfigShape = {
|
|
78
78
|
catalogs?: Catalogs
|
|
79
79
|
cookieKey: string
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/i18n",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"typecheck": "tsc --noEmit"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@zerotal/core": "1.7.
|
|
36
|
+
"@zerotal/core": "1.7.3"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"typescript": "^5.8.0"
|
package/src/LocaleMiddleware.ts
CHANGED
|
@@ -5,9 +5,9 @@ import type { Translator } from "./Translator.ts";
|
|
|
5
5
|
import type { I18nConfigShape } from "./config.ts";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Resolves the request locale, exposes `ctx.locale` + `ctx.
|
|
8
|
+
* Resolves the request locale, exposes `ctx.locale` + `ctx.__()`, and runs the
|
|
9
9
|
* rest of the pipeline inside `I18nContext.run()` so the `Lang` facade and the
|
|
10
|
-
* global `
|
|
10
|
+
* global `__()` helper see the right locale. Configured by I18nProvider via
|
|
11
11
|
* `LocaleMiddleware.configure()` and registered with `app.useOnce()`.
|
|
12
12
|
*/
|
|
13
13
|
export class LocaleMiddleware implements Pipe<HttpContext> {
|
|
@@ -26,7 +26,7 @@ export class LocaleMiddleware implements Pipe<HttpContext> {
|
|
|
26
26
|
|
|
27
27
|
const locale = resolveLocale(http.request, config);
|
|
28
28
|
http.locale = locale;
|
|
29
|
-
http.
|
|
29
|
+
http.__ = (key, replacements, loc) => translator.translate(key, replacements, loc ?? locale);
|
|
30
30
|
|
|
31
31
|
return I18nContext.run(locale, () => next());
|
|
32
32
|
}
|
package/src/Translator.ts
CHANGED
|
@@ -46,12 +46,21 @@ export class Translator {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* Translate `key`.
|
|
50
|
-
*
|
|
49
|
+
* Translate `key`.
|
|
50
|
+
*
|
|
51
|
+
* An untranslated key falls through to *itself* — and then goes on to be
|
|
52
|
+
* pluralized and interpolated exactly like a catalog hit would be. That last
|
|
53
|
+
* part is what lets the source language skip having a catalog: write
|
|
54
|
+
* `translate('{count} comments', { count: 3 })` and English needs no `en.json`
|
|
55
|
+
* to answer with "3 comments", because the key already is the answer.
|
|
56
|
+
*
|
|
57
|
+
* The fallback is therefore never a broken-looking screen. A key nobody has
|
|
58
|
+
* translated renders as the English the developer typed, in a locale that has
|
|
59
|
+
* no entry for it, on the day the string is written.
|
|
51
60
|
*
|
|
52
61
|
* @example
|
|
53
|
-
* t.translate('
|
|
54
|
-
* t.translate('apples', { count: 2 }); // pipe-pluralized
|
|
62
|
+
* t.translate('Hello, {name}!', { name: 'Alice' }, 'fr');
|
|
63
|
+
* t.translate('{count} apple|{count} apples', { count: 2 }); // pipe-pluralized
|
|
55
64
|
*/
|
|
56
65
|
translate(key: string, replacements: Replacements = {}, locale?: string): string {
|
|
57
66
|
const loc = this._resolve(locale);
|
|
@@ -59,14 +68,23 @@ export class Translator {
|
|
|
59
68
|
if (msg === undefined && loc !== this.fallbackLocale) {
|
|
60
69
|
msg = this._lookup(key, this.fallbackLocale);
|
|
61
70
|
}
|
|
62
|
-
|
|
71
|
+
// The key is the source string, so it is a usable message on its own.
|
|
72
|
+
if (msg === undefined) msg = key;
|
|
63
73
|
if (typeof replacements.count === "number") {
|
|
64
74
|
msg = this._pluralize(msg, replacements.count);
|
|
65
75
|
}
|
|
66
76
|
return this._interpolate(msg, replacements);
|
|
67
77
|
}
|
|
68
78
|
|
|
69
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Look up a key flat first, then as a nested path.
|
|
81
|
+
*
|
|
82
|
+
* Flat has to come first, because an English key is a sentence: "Signed out."
|
|
83
|
+
* and "It costs $5. Really." both contain dots that are punctuation, not
|
|
84
|
+
* structure. Splitting those on `.` would send the lookup hunting for a
|
|
85
|
+
* `Signed out` object that no catalog has. Nested traversal stays as the
|
|
86
|
+
* second attempt so grouped catalogs keep resolving.
|
|
87
|
+
*/
|
|
70
88
|
private _lookup(key: string, locale: string): string | undefined {
|
|
71
89
|
const cat = this.catalogs[locale];
|
|
72
90
|
if (!cat) return undefined;
|
package/src/augment.ts
CHANGED
|
@@ -9,14 +9,29 @@ declare module "@zerotal/core" {
|
|
|
9
9
|
|
|
10
10
|
interface HttpContext {
|
|
11
11
|
/**
|
|
12
|
-
* Translate
|
|
12
|
+
* Translate an English source string using this request's resolved locale.
|
|
13
13
|
*
|
|
14
14
|
* @example
|
|
15
|
-
* ctx.
|
|
16
|
-
* ctx.
|
|
15
|
+
* ctx.__('Hello, {name}!', { name: 'Alice' });
|
|
16
|
+
* ctx.__('Hello, {name}!', { name: 'Alice' }, 'fr'); // explicit locale
|
|
17
17
|
*/
|
|
18
|
-
|
|
18
|
+
__(key: string, replacements?: Replacements, locale?: string): string;
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* `__()` without an import.
|
|
24
|
+
*
|
|
25
|
+
* `I18nProvider` puts the helper on `globalThis` when it boots, and this is the
|
|
26
|
+
* declaration that lets a route or a view call `__("Email")` with no import
|
|
27
|
+
* line. The signature is the named export's, so nothing about a call site
|
|
28
|
+
* changes except what is missing from the top of the file.
|
|
29
|
+
*
|
|
30
|
+
* `var` rather than `const`, because only `var` in a `declare global` block
|
|
31
|
+
* creates a matching property on `globalThis` for the assignment to satisfy.
|
|
32
|
+
*/
|
|
33
|
+
declare global {
|
|
34
|
+
var __: (key: string, replacements?: Replacements, locale?: string) => string;
|
|
35
|
+
}
|
|
36
|
+
|
|
22
37
|
export {};
|
package/src/facades/Lang.ts
CHANGED
|
@@ -12,11 +12,19 @@ import type { Replacements } from "../types.ts";
|
|
|
12
12
|
export const Lang = createFacade<"i18n">("i18n");
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
15
|
+
* The global translation helper.
|
|
16
|
+
*
|
|
17
|
+
* The argument is the English sentence, not a symbolic name: `__('Email')`,
|
|
18
|
+
* never `__('auth.email')`. English is the source language, so the source text
|
|
19
|
+
* doubles as the key — which means the default locale needs no catalog at all,
|
|
20
|
+
* and a string that has never been translated still renders as the words the
|
|
21
|
+
* developer wrote.
|
|
16
22
|
*
|
|
17
23
|
* @example
|
|
18
|
-
*
|
|
24
|
+
* __('Email');
|
|
25
|
+
* __('{count} unread', { count: 5 });
|
|
26
|
+
* __('Signed out.', {}, 'zu'); // explicit locale, for queue jobs
|
|
19
27
|
*/
|
|
20
|
-
export function
|
|
28
|
+
export function __(key: string, replacements?: Replacements, locale?: string): string {
|
|
21
29
|
return Lang.translate(key, replacements, locale);
|
|
22
30
|
}
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,7 @@ export { I18nProvider } from "./provider/I18nProvider.ts";
|
|
|
12
12
|
export { LocaleMiddleware } from "./LocaleMiddleware.ts";
|
|
13
13
|
|
|
14
14
|
// Facade + helper
|
|
15
|
-
export { Lang,
|
|
15
|
+
export { Lang, __ } from "./facades/Lang.ts";
|
|
16
16
|
|
|
17
17
|
// Config
|
|
18
18
|
export { I18nConfig } from "./config.ts";
|
|
@@ -5,6 +5,7 @@ import { loadCatalogs } from "../loadCatalogs.ts";
|
|
|
5
5
|
import { LocaleMiddleware } from "../LocaleMiddleware.ts";
|
|
6
6
|
import { I18nConfig } from "../config.ts";
|
|
7
7
|
import type { I18nConfigShape } from "../config.ts";
|
|
8
|
+
import { __ } from "../facades/Lang.ts";
|
|
8
9
|
import "../augment.ts";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -50,5 +51,15 @@ export class I18nProvider extends ServiceProvider {
|
|
|
50
51
|
const config = await this._resolveConfig();
|
|
51
52
|
LocaleMiddleware.configure(translator, config);
|
|
52
53
|
this.app.useOnce(LocaleMiddleware);
|
|
54
|
+
|
|
55
|
+
// `__()` without an import, for every route, view, job and notification in
|
|
56
|
+
// the app. Installed here rather than at module load because it is only
|
|
57
|
+
// honest once there is a translator behind it: a global that resolves
|
|
58
|
+
// against an unbound container would answer every string with itself, which
|
|
59
|
+
// reads as "not translated yet" rather than as "i18n is not installed".
|
|
60
|
+
//
|
|
61
|
+
// The declaration that makes it type-check lives in `augment.ts`, beside the
|
|
62
|
+
// `HttpContext` one, since both are the same kind of ambient promise.
|
|
63
|
+
(globalThis as { __?: typeof __ }).__ = __;
|
|
53
64
|
}
|
|
54
65
|
}
|