@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 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.0",
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.0"
36
+ "@zerotal/core": "1.7.3"
37
37
  },
38
38
  "devDependencies": {
39
39
  "typescript": "^5.8.0"
@@ -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.t()`, and runs the
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 `t()` helper see the right locale. Configured by I18nProvider via
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.t = (key, replacements, loc) => translator.translate(key, replacements, loc ?? locale);
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`. Missing keys return the key itself (so the UI degrades
50
- * visibly rather than throwing).
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('welcome.greeting', { name: 'Alice' }, 'fr');
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
- if (msg === undefined) return key;
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
- /** Look up a key as a flat dotted key first, then as a nested path. */
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 a key using this request's resolved locale.
12
+ * Translate an English source string using this request's resolved locale.
13
13
  *
14
14
  * @example
15
- * ctx.t('welcome.greeting', { name: 'Alice' });
16
- * ctx.t('welcome.greeting', { name: 'Alice' }, 'fr'); // explicit locale
15
+ * ctx.__('Hello, {name}!', { name: 'Alice' });
16
+ * ctx.__('Hello, {name}!', { name: 'Alice' }, 'fr'); // explicit locale
17
17
  */
18
- t(key: string, replacements?: Replacements, locale?: string): string;
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 {};
@@ -12,11 +12,19 @@ import type { Replacements } from "../types.ts";
12
12
  export const Lang = createFacade<"i18n">("i18n");
13
13
 
14
14
  /**
15
- * Convenience global translation helper — handy as a view global.
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
- * t('messages.unread', { count: 5 });
24
+ * __('Email');
25
+ * __('{count} unread', { count: 5 });
26
+ * __('Signed out.', {}, 'zu'); // explicit locale, for queue jobs
19
27
  */
20
- export function t(key: string, replacements?: Replacements, locale?: string): string {
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, t } from "./facades/Lang.ts";
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
  }