@tatara/i18n 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,3 +1,35 @@
1
1
  # @tatara/i18n
2
2
 
3
- Part of [tatara](https://github.com/mohammed-bageri/tatara-cli).
3
+ Part of [tatara](https://github.com/mohammed-bageri/tatara-cli). Dictionaries, `t()`, plurals,
4
+ language detection for Hono and localised zod messages. No i18n library underneath.
5
+
6
+ ```ts
7
+ import { createI18n, loadDictionaries, zodErrorMap } from "@tatara/i18n";
8
+ import { z } from "zod";
9
+
10
+ const i18n = createI18n({
11
+ // { en: { common: {…}, errors: {…} }, ar: {…} } from locales/<lang>/<ns>.json
12
+ dictionaries: await loadDictionaries({ dir: `${process.cwd()}/locales` }),
13
+ defaultLanguage: "en",
14
+ detection: { cookie: "language", persist: { maxAge: 31_536_000, httpOnly: true, sameSite: "Lax" } },
15
+ });
16
+
17
+ app.use(i18n.detectLanguage); // ?lang, cookie, Accept-Language → c.var.language
18
+ app.use(i18n.preferLanguage((c) => c.get("user")?.language)); // unless ?lang is set
19
+ z.config({ customError: zodErrorMap(i18n, { prefix: "errors:validation." }) });
20
+
21
+ const t = i18n.translator(c.get("language"));
22
+ t("common:items", { count: 3 }); // items_one / items_other / items_zero
23
+ t("errors:notFound", { defaultValue: "Not found" });
24
+ ```
25
+
26
+ - Keys are `a.b.c` or `namespace:a.b.c`; a namespace is a top-level key of the dictionary.
27
+ - Variables are `{name}` or `{{name}}`; unknown ones are left as written.
28
+ - Missing keys fall back to the default language, then `defaultValue` (or `t`'s `fallback`), then the key.
29
+ - `currentLanguage()` is the request's language anywhere below `detectLanguage` (the zod map uses it);
30
+ `withLanguage(lang, fn)` sets it for jobs and scripts.
31
+ - `zodErrorMap` keys: `required`, `invalid_type`, `too_small`, `too_big`, `invalid_format`,
32
+ `invalid_value`, `custom` under `prefix` (default `validation.`), or full keys via `keys`. A kind
33
+ with no translation keeps zod's message.
34
+ - `pick(lang, { en, ar })` chooses a per-language value; `withEnumLabels(row, { status: "jobs:status" }, t)`
35
+ adds `status_label`.
package/dist/index.d.mts CHANGED
@@ -1,16 +1,98 @@
1
- import { Env, MiddlewareHandler } from "hono";
1
+ import { Context, Env, MiddlewareHandler } from "hono";
2
+ import { CookieOptions } from "hono/utils/cookie";
3
+ //#region src/enum-labels.d.ts
4
+ type Translate = (key: string) => string;
5
+ /**
6
+ * A copy of `data` with a `<field>_label` for each field in `enumMap` that has a value:
7
+ * `enumMap` maps a field to its key prefix, so `{ status: "jobs:status" }` reads `jobs:status.DRAFT`.
8
+ */
9
+ export declare function withEnumLabels<T extends Record<string, unknown>>(data: T, enumMap: Record<string, string>, t: Translate): T & Record<string, unknown>;
10
+ /** `withEnumLabels` for every item. */
11
+ export declare function withEnumLabelsAll<T extends Record<string, unknown>>(items: T[], enumMap: Record<string, string>, t: Translate): Array<T & Record<string, unknown>>;
12
+ //#endregion
13
+ //#region src/load.d.ts
14
+ interface LoadDictionariesOptions {
15
+ /** holds `<language>/<namespace>.json`, e.g. `${process.cwd()}/locales` */
16
+ dir: string;
17
+ /** default: every sub-directory of `dir` */
18
+ languages?: readonly string[];
19
+ /** default: every `*.json` file found in any language */
20
+ namespaces?: readonly string[];
21
+ /** a namespace file one language lacks; that namespace is `{}` there */
22
+ onMissing?: (language: string, namespace: string) => void;
23
+ }
24
+ /**
25
+ * Reads `<dir>/<language>/<namespace>.json` into `{ [language]: { [namespace]: … } }`, so
26
+ * `t(language, "namespace:key")` finds them. Namespaces are discovered from the files.
27
+ */
28
+ export declare function loadDictionaries(options: LoadDictionariesOptions): Promise<Record<string, Dictionary>>;
29
+ //#endregion
30
+ //#region src/zod.d.ts
31
+ /** The fields of a zod issue the messages use; zod's own issue type is assignable to it. */
32
+ interface ZodIssueLike {
33
+ code?: string;
34
+ input?: unknown;
35
+ expected?: unknown;
36
+ minimum?: unknown;
37
+ maximum?: unknown;
38
+ values?: readonly unknown[];
39
+ }
40
+ type ZodMessageKind = "required" | "invalid_type" | "too_small" | "too_big" | "invalid_format" | "invalid_value" | "custom";
41
+ interface ZodErrorMapOptions {
42
+ /** key prefix; a message's key is `${prefix}${kind}` (default "validation.") */
43
+ prefix?: string;
44
+ /** full keys for some kinds, overriding the prefix */
45
+ keys?: Partial<Record<ZodMessageKind, string>>;
46
+ }
47
+ /**
48
+ * A zod error map giving messages in `i18n.currentLanguage()` (the request's language under
49
+ * `detectLanguage`). Register it once: `z.config({ customError: zodErrorMap(i18n) })`.
50
+ * Kinds without a translation keep zod's own message.
51
+ */
52
+ export declare function zodErrorMap(i18n: I18n, options?: ZodErrorMapOptions): (issue: ZodIssueLike) => {
53
+ message: string;
54
+ } | undefined;
55
+ //#endregion
2
56
  //#region src/index.d.ts
3
57
  export type Dictionary = {
4
58
  [key: string]: string | Dictionary;
5
59
  };
60
+ /** Variables for a message; `count` (a number) also picks the plural form (`key_one`, `key_other`, `key_zero`). */
61
+ export type TranslateVars = Record<string, unknown>;
62
+ /** A translator bound to one language, i18next-style: `t("errors:notFound", { name, count, defaultValue })`. */
63
+ export type Translator = (key: string, vars?: TranslateVars & {
64
+ defaultValue?: string;
65
+ }) => string;
66
+ export interface LanguageDetectionOptions {
67
+ /** query parameter, or `false` to ignore the query (default "lang") */
68
+ query?: string | false;
69
+ /** cookie name, or `false` to ignore cookies (default "lang") */
70
+ cookie?: string | false;
71
+ /** read Accept-Language (default true) */
72
+ header?: boolean;
73
+ /** write the detected language to the cookie when it differs, e.g. `{ maxAge: 31536000, httpOnly: true }` */
74
+ persist?: CookieOptions;
75
+ }
6
76
  export interface I18nOptions {
7
- /** language code → strings, e.g. { en, ar } from src/locales */
77
+ /** language code → strings, e.g. { en, ar } from src/locales, or `loadDictionaries()` */
8
78
  dictionaries: Record<string, Dictionary>;
9
79
  defaultLanguage: string;
80
+ detection?: LanguageDetectionOptions;
10
81
  }
11
- export declare function createI18n({ dictionaries, defaultLanguage }: I18nOptions): {
82
+ export declare function createI18n({ dictionaries, defaultLanguage, detection }: I18nOptions): {
12
83
  languages: string[];
13
- t: (language: string, key: string, fallback?: string, vars?: Record<string, string>) => string;
84
+ defaultLanguage: string;
85
+ t: (language: string, key: string, fallback?: string, vars?: TranslateVars) => string;
86
+ translator: (language: string | null | undefined) => Translator;
87
+ exists: (key: string, language?: string) => boolean;
88
+ resource: (language: string, key: string) => string | Dictionary | undefined;
89
+ resolveLanguage: (language: string | null | undefined) => string;
90
+ currentLanguage: () => string;
91
+ withLanguage: <T>(language: string, fn: () => T) => T;
92
+ pick: (language: string, values: Partial<Record<string, string | null | undefined>>) => string;
14
93
  detectLanguage: MiddlewareHandler<Env>;
94
+ preferLanguage: (preferred: (c: Context) => string | null | undefined | Promise<string | null | undefined>) => MiddlewareHandler<Env>;
15
95
  };
16
- //#endregion
96
+ export type I18n = ReturnType<typeof createI18n>;
97
+ //#endregion
98
+ export type { LoadDictionariesOptions, ZodErrorMapOptions, ZodIssueLike, ZodMessageKind };
package/dist/index.mjs CHANGED
@@ -1,33 +1,234 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { getCookie, setCookie } from "hono/cookie";
1
3
  import { createMiddleware } from "hono/factory";
4
+ import { readFile, readdir } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ //#region src/enum-labels.ts
7
+ /**
8
+ * A copy of `data` with a `<field>_label` for each field in `enumMap` that has a value:
9
+ * `enumMap` maps a field to its key prefix, so `{ status: "jobs:status" }` reads `jobs:status.DRAFT`.
10
+ */
11
+ function withEnumLabels(data, enumMap, t) {
12
+ const result = { ...data };
13
+ for (const [field, prefix] of Object.entries(enumMap)) {
14
+ const value = data[field];
15
+ if (value !== void 0 && value !== null) result[`${field}_label`] = t(`${prefix}.${value}`);
16
+ }
17
+ return result;
18
+ }
19
+ /** `withEnumLabels` for every item. */
20
+ function withEnumLabelsAll(items, enumMap, t) {
21
+ return items.map((item) => withEnumLabels(item, enumMap, t));
22
+ }
23
+ //#endregion
24
+ //#region src/load.ts
25
+ async function list(dir, pick) {
26
+ return (await readdir(dir, { withFileTypes: true })).flatMap((e) => pick(e.name, e.isDirectory()) ?? []).sort();
27
+ }
28
+ /**
29
+ * Reads `<dir>/<language>/<namespace>.json` into `{ [language]: { [namespace]: … } }`, so
30
+ * `t(language, "namespace:key")` finds them. Namespaces are discovered from the files.
31
+ */
32
+ async function loadDictionaries(options) {
33
+ const languages = options.languages ?? await list(options.dir, (name, isDir) => isDir ? name : void 0);
34
+ const files = /* @__PURE__ */ new Map();
35
+ for (const language of languages) {
36
+ const names = await list(join(options.dir, language), (name, isDir) => !isDir && name.endsWith(".json") ? name.slice(0, -5) : void 0).catch(() => []);
37
+ files.set(language, names);
38
+ }
39
+ const namespaces = options.namespaces ?? [...new Set([...files.values()].flat())].sort();
40
+ const result = {};
41
+ for (const language of languages) {
42
+ const dictionary = {};
43
+ for (const namespace of namespaces) {
44
+ if (!files.get(language)?.includes(namespace)) {
45
+ options.onMissing?.(language, namespace);
46
+ dictionary[namespace] = {};
47
+ continue;
48
+ }
49
+ const text = await readFile(join(options.dir, language, `${namespace}.json`), "utf8");
50
+ dictionary[namespace] = JSON.parse(text);
51
+ }
52
+ result[language] = dictionary;
53
+ }
54
+ return result;
55
+ }
56
+ //#endregion
57
+ //#region src/zod.ts
58
+ function describe(issue) {
59
+ switch (issue.code) {
60
+ case "invalid_type": return issue.input === void 0 ? ["required", {}] : ["invalid_type", {
61
+ expected: issue.expected,
62
+ received: typeof issue.input
63
+ }];
64
+ case "too_small": return ["too_small", { minimum: String(issue.minimum) }];
65
+ case "too_big": return ["too_big", { maximum: String(issue.maximum) }];
66
+ case "invalid_format": return ["invalid_format", {}];
67
+ case "invalid_value": return ["invalid_value", { options: (issue.values ?? []).map(String).join(", ") }];
68
+ case "custom": return ["custom", {}];
69
+ default: return;
70
+ }
71
+ }
72
+ /**
73
+ * A zod error map giving messages in `i18n.currentLanguage()` (the request's language under
74
+ * `detectLanguage`). Register it once: `z.config({ customError: zodErrorMap(i18n) })`.
75
+ * Kinds without a translation keep zod's own message.
76
+ */
77
+ function zodErrorMap(i18n, options = {}) {
78
+ const prefix = options.prefix ?? "validation.";
79
+ return (issue) => {
80
+ const described = describe(issue);
81
+ if (!described) return void 0;
82
+ const [kind, vars] = described;
83
+ const key = options.keys?.[kind] ?? `${prefix}${kind}`;
84
+ const language = i18n.currentLanguage();
85
+ if (!i18n.exists(key, language)) return void 0;
86
+ return { message: i18n.t(language, key, key, vars) };
87
+ };
88
+ }
89
+ //#endregion
2
90
  //#region src/index.ts
91
+ /** @tatara/i18n: dictionaries, `t()`, language detection for Hono and localised zod messages. */
92
+ function path(key) {
93
+ const ns = key.indexOf(":");
94
+ const parts = (ns === -1 ? key : key.slice(ns + 1)).split(".");
95
+ return ns === -1 ? parts : [key.slice(0, ns), ...parts];
96
+ }
3
97
  function lookup(dict, key) {
4
98
  let node = dict;
5
- for (const part of key.split(".")) {
99
+ for (const part of path(key)) {
6
100
  if (!node || typeof node === "string") return void 0;
7
101
  node = node[part];
8
102
  }
9
- return typeof node === "string" ? node : void 0;
103
+ return node;
10
104
  }
11
- function createI18n({ dictionaries, defaultLanguage }) {
105
+ const VARIABLE = /\{\{\s*(\w+)\s*\}\}|\{(\w+)\}/g;
106
+ function interpolate(text, vars) {
107
+ return text.replace(VARIABLE, (match, double, single) => {
108
+ const value = vars[double ?? single];
109
+ return value === void 0 || value === null ? match : String(value);
110
+ });
111
+ }
112
+ function createI18n({ dictionaries, defaultLanguage, detection = {} }) {
12
113
  const languages = Object.keys(dictionaries);
13
- /** Translates `key` ("errors.NOT_FOUND"), falling back to the default language, then `fallback`. */
114
+ const supported = new Set(languages);
115
+ const storage = new AsyncLocalStorage();
116
+ const pluralRules = /* @__PURE__ */ new Map();
117
+ const queryKey = detection.query ?? "lang";
118
+ const cookieName = detection.cookie ?? "lang";
119
+ function plural(language, count) {
120
+ let rules = pluralRules.get(language);
121
+ if (!rules) {
122
+ rules = new Intl.PluralRules(language);
123
+ pluralRules.set(language, rules);
124
+ }
125
+ return rules.select(count);
126
+ }
127
+ /** `language` when it is supported, otherwise the default language. */
128
+ function resolveLanguage(language) {
129
+ return language && supported.has(language) ? language : defaultLanguage;
130
+ }
131
+ function find(language, key, count) {
132
+ for (const lang of language === defaultLanguage ? [language] : [language, defaultLanguage]) {
133
+ const dict = dictionaries[lang];
134
+ if (typeof count === "number") {
135
+ const forms = count === 0 ? ["zero", plural(lang, count)] : [plural(lang, count)];
136
+ for (const form of forms) {
137
+ const text = lookup(dict, `${key}_${form}`);
138
+ if (typeof text === "string") return text;
139
+ }
140
+ }
141
+ const text = lookup(dict, key);
142
+ if (typeof text === "string") return text;
143
+ }
144
+ }
145
+ /** Translates `key` ("errors.NOT_FOUND" or "errors:NOT_FOUND"), falling back to the default language, then `fallback`. */
14
146
  function t(language, key, fallback = key, vars = {}) {
15
- return (lookup(dictionaries[language], key) ?? lookup(dictionaries[defaultLanguage], key) ?? fallback).replace(/\{(\w+)\}/g, (_, name) => vars[name] ?? `{${name}}`);
147
+ return interpolate(find(language, key, vars.count) ?? fallback, vars);
148
+ }
149
+ /** A translator for one language (unsupported → default); missing keys give `defaultValue`, else the key. */
150
+ function translator(language) {
151
+ const lang = resolveLanguage(language);
152
+ return (key, vars = {}) => t(lang, key, vars.defaultValue ?? key, vars);
153
+ }
154
+ /** True when `key` has a string in `language` (or the default language). */
155
+ function exists(key, language = defaultLanguage) {
156
+ return find(resolveLanguage(language), key, void 0) !== void 0;
157
+ }
158
+ /** The raw entry at `key` in one language's dictionary, no fallback: a string, a subtree or undefined. */
159
+ function resource(language, key) {
160
+ return lookup(dictionaries[language], key);
161
+ }
162
+ /** The request's language inside `detectLanguage`/`withLanguage`, else the default language. */
163
+ function currentLanguage() {
164
+ return storage.getStore() ?? defaultLanguage;
165
+ }
166
+ /** Runs `fn` with `currentLanguage()` set to `language` (for jobs, CLI, tests). */
167
+ function withLanguage(language, fn) {
168
+ return storage.run(resolveLanguage(language), fn);
169
+ }
170
+ /** The value for `language` from per-language values (e.g. `{ en: row.nameEn, ar: row.nameAr }`); empty falls back to the default language. */
171
+ function pick(language, values) {
172
+ return values[language] || values[defaultLanguage] || "";
173
+ }
174
+ function accepted(header) {
175
+ const ranked = header.split(",").map((part, index) => {
176
+ const [tag = "", ...params] = part.trim().toLowerCase().split(";");
177
+ const q = params.map((p) => /^\s*q=([\d.]+)/.exec(p)?.[1]).find(Boolean);
178
+ return {
179
+ tag: tag.trim(),
180
+ q: q === void 0 ? 1 : Number(q),
181
+ index
182
+ };
183
+ }).filter((entry) => entry.tag && entry.q > 0).sort((a, b) => b.q - a.q || a.index - b.index);
184
+ for (const { tag } of ranked) {
185
+ const match = languages.find((l) => l.toLowerCase() === tag) ?? tag.split("-")[0];
186
+ if (match && supported.has(match)) return match;
187
+ }
188
+ }
189
+ function apply(c, language, next) {
190
+ c.set("language", language);
191
+ c.header("content-language", language);
192
+ return storage.run(language, next);
193
+ }
194
+ function explicitQuery(c) {
195
+ const value = queryKey ? c.req.query(queryKey) : void 0;
196
+ return value && supported.has(value) ? value : void 0;
197
+ }
198
+ /** ?lang, then the `lang` cookie, then Accept-Language; sets `c.var.language` and `currentLanguage()`. */
199
+ const detectLanguage = createMiddleware(async (c, next) => {
200
+ const fromCookie = cookieName ? getCookie(c, cookieName) : void 0;
201
+ const language = explicitQuery(c) ?? (fromCookie && supported.has(fromCookie) ? fromCookie : void 0) ?? (detection.header === false ? void 0 : accepted(c.req.header("accept-language") ?? "")) ?? defaultLanguage;
202
+ if (detection.persist && cookieName && fromCookie !== language) setCookie(c, cookieName, language, detection.persist);
203
+ await apply(c, language, next);
204
+ });
205
+ /**
206
+ * Replaces the detected language with `preferred(c)` (e.g. the signed-in user's stored
207
+ * preference) when it is supported, unless the request asked for one with the query
208
+ * parameter. Mount after `detectLanguage` and after whatever sets the user.
209
+ */
210
+ function preferLanguage(preferred) {
211
+ return async (c, next) => {
212
+ if (explicitQuery(c)) return next();
213
+ const language = await preferred(c);
214
+ if (!language || !supported.has(language)) return next();
215
+ await apply(c, language, next);
216
+ };
16
217
  }
17
218
  return {
18
219
  languages,
220
+ defaultLanguage,
19
221
  t,
20
- detectLanguage: createMiddleware(async (c, next) => {
21
- const language = [
22
- c.req.query("lang"),
23
- /(?:^|;\s*)lang=([^;]+)/.exec(c.req.header("cookie") ?? "")?.[1],
24
- ...(c.req.header("accept-language") ?? "").split(",").map((part) => part.split(";")[0]?.trim().toLowerCase().split("-")[0])
25
- ].find((l) => l && languages.includes(l)) ?? defaultLanguage;
26
- c.set("language", language);
27
- c.header("content-language", language);
28
- await next();
29
- })
222
+ translator,
223
+ exists,
224
+ resource,
225
+ resolveLanguage,
226
+ currentLanguage,
227
+ withLanguage,
228
+ pick,
229
+ detectLanguage,
230
+ preferLanguage
30
231
  };
31
232
  }
32
233
  //#endregion
33
- export { createI18n };
234
+ export { createI18n, loadDictionaries, withEnumLabels, withEnumLabelsAll, zodErrorMap };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tatara/i18n",
3
- "version": "0.1.0",
4
- "description": "tatara i18n: dictionaries, t() and language detection for Hono",
3
+ "version": "0.2.0",
4
+ "description": "tatara i18n: dictionaries, t(), plurals, language detection for Hono and localised zod messages",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "sideEffects": false,
@@ -23,7 +23,8 @@
23
23
  "hono": "^4.13.13"
24
24
  },
25
25
  "devDependencies": {
26
- "hono": "^4.13.13"
26
+ "hono": "^4.13.13",
27
+ "zod": "^4.6.7"
27
28
  },
28
29
  "publishConfig": {
29
30
  "access": "public"
@@ -0,0 +1,27 @@
1
+ type Translate = (key: string) => string;
2
+
3
+ /**
4
+ * A copy of `data` with a `<field>_label` for each field in `enumMap` that has a value:
5
+ * `enumMap` maps a field to its key prefix, so `{ status: "jobs:status" }` reads `jobs:status.DRAFT`.
6
+ */
7
+ export function withEnumLabels<T extends Record<string, unknown>>(
8
+ data: T,
9
+ enumMap: Record<string, string>,
10
+ t: Translate,
11
+ ): T & Record<string, unknown> {
12
+ const result: Record<string, unknown> = { ...data };
13
+ for (const [field, prefix] of Object.entries(enumMap)) {
14
+ const value = data[field];
15
+ if (value !== undefined && value !== null) result[`${field}_label`] = t(`${prefix}.${value}`);
16
+ }
17
+ return result as T & Record<string, unknown>;
18
+ }
19
+
20
+ /** `withEnumLabels` for every item. */
21
+ export function withEnumLabelsAll<T extends Record<string, unknown>>(
22
+ items: T[],
23
+ enumMap: Record<string, string>,
24
+ t: Translate,
25
+ ): Array<T & Record<string, unknown>> {
26
+ return items.map((item) => withEnumLabels(item, enumMap, t));
27
+ }
package/src/index.ts CHANGED
@@ -1,54 +1,221 @@
1
- /** @tatara/i18n: dictionaries, `t()` and language detection for Hono. */
2
- import type { Env, MiddlewareHandler } from "hono";
1
+ /** @tatara/i18n: dictionaries, `t()`, language detection for Hono and localised zod messages. */
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import type { Context, Env, MiddlewareHandler } from "hono";
4
+ import { getCookie, setCookie } from "hono/cookie";
3
5
  import { createMiddleware } from "hono/factory";
6
+ import type { CookieOptions } from "hono/utils/cookie";
7
+
8
+ export { withEnumLabels, withEnumLabelsAll } from "./enum-labels.ts";
9
+ export { type LoadDictionariesOptions, loadDictionaries } from "./load.ts";
10
+ export {
11
+ type ZodErrorMapOptions,
12
+ type ZodIssueLike,
13
+ type ZodMessageKind,
14
+ zodErrorMap,
15
+ } from "./zod.ts";
4
16
 
5
17
  export type Dictionary = { [key: string]: string | Dictionary };
6
18
 
19
+ /** Variables for a message; `count` (a number) also picks the plural form (`key_one`, `key_other`, `key_zero`). */
20
+ export type TranslateVars = Record<string, unknown>;
21
+
22
+ /** A translator bound to one language, i18next-style: `t("errors:notFound", { name, count, defaultValue })`. */
23
+ export type Translator = (key: string, vars?: TranslateVars & { defaultValue?: string }) => string;
24
+
25
+ export interface LanguageDetectionOptions {
26
+ /** query parameter, or `false` to ignore the query (default "lang") */
27
+ query?: string | false;
28
+ /** cookie name, or `false` to ignore cookies (default "lang") */
29
+ cookie?: string | false;
30
+ /** read Accept-Language (default true) */
31
+ header?: boolean;
32
+ /** write the detected language to the cookie when it differs, e.g. `{ maxAge: 31536000, httpOnly: true }` */
33
+ persist?: CookieOptions;
34
+ }
35
+
7
36
  export interface I18nOptions {
8
- /** language code → strings, e.g. { en, ar } from src/locales */
37
+ /** language code → strings, e.g. { en, ar } from src/locales, or `loadDictionaries()` */
9
38
  dictionaries: Record<string, Dictionary>;
10
39
  defaultLanguage: string;
40
+ detection?: LanguageDetectionOptions;
11
41
  }
12
42
 
13
- function lookup(dict: Dictionary | undefined, key: string): string | undefined {
14
- let node: string | Dictionary | undefined = dict;
15
- for (const part of key.split(".")) {
43
+ type Node = string | Dictionary | undefined;
44
+
45
+ // "ns:a.b" reads dictionary.ns.a.b, so a namespace is just the top-level key.
46
+ function path(key: string): string[] {
47
+ const ns = key.indexOf(":");
48
+ const parts = (ns === -1 ? key : key.slice(ns + 1)).split(".");
49
+ return ns === -1 ? parts : [key.slice(0, ns), ...parts];
50
+ }
51
+
52
+ function lookup(dict: Dictionary | undefined, key: string): Node {
53
+ let node: Node = dict;
54
+ for (const part of path(key)) {
16
55
  if (!node || typeof node === "string") return undefined;
17
56
  node = node[part];
18
57
  }
19
- return typeof node === "string" ? node : undefined;
58
+ return node;
59
+ }
60
+
61
+ const VARIABLE = /\{\{\s*(\w+)\s*\}\}|\{(\w+)\}/g;
62
+
63
+ function interpolate(text: string, vars: TranslateVars): string {
64
+ return text.replace(VARIABLE, (match, double?: string, single?: string) => {
65
+ const value = vars[(double ?? single) as string];
66
+ return value === undefined || value === null ? match : String(value);
67
+ });
20
68
  }
21
69
 
22
- export function createI18n({ dictionaries, defaultLanguage }: I18nOptions) {
70
+ export function createI18n({ dictionaries, defaultLanguage, detection = {} }: I18nOptions) {
23
71
  const languages = Object.keys(dictionaries);
72
+ const supported = new Set(languages);
73
+ const storage = new AsyncLocalStorage<string>();
74
+ const pluralRules = new Map<string, Intl.PluralRules>();
75
+ const queryKey = detection.query ?? "lang";
76
+ const cookieName = detection.cookie ?? "lang";
77
+
78
+ function plural(language: string, count: number): string {
79
+ let rules = pluralRules.get(language);
80
+ if (!rules) {
81
+ rules = new Intl.PluralRules(language);
82
+ pluralRules.set(language, rules);
83
+ }
84
+ return rules.select(count);
85
+ }
86
+
87
+ /** `language` when it is supported, otherwise the default language. */
88
+ function resolveLanguage(language: string | null | undefined): string {
89
+ return language && supported.has(language) ? language : defaultLanguage;
90
+ }
91
+
92
+ function find(language: string, key: string, count: unknown): string | undefined {
93
+ for (const lang of language === defaultLanguage ? [language] : [language, defaultLanguage]) {
94
+ const dict = dictionaries[lang];
95
+ if (typeof count === "number") {
96
+ const forms = count === 0 ? ["zero", plural(lang, count)] : [plural(lang, count)];
97
+ for (const form of forms) {
98
+ const text = lookup(dict, `${key}_${form}`);
99
+ if (typeof text === "string") return text;
100
+ }
101
+ }
102
+ const text = lookup(dict, key);
103
+ if (typeof text === "string") return text;
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ /** Translates `key` ("errors.NOT_FOUND" or "errors:NOT_FOUND"), falling back to the default language, then `fallback`. */
109
+ function t(language: string, key: string, fallback = key, vars: TranslateVars = {}): string {
110
+ return interpolate(find(language, key, vars.count) ?? fallback, vars);
111
+ }
112
+
113
+ /** A translator for one language (unsupported → default); missing keys give `defaultValue`, else the key. */
114
+ function translator(language: string | null | undefined): Translator {
115
+ const lang = resolveLanguage(language);
116
+ return (key, vars = {}) => t(lang, key, vars.defaultValue ?? key, vars);
117
+ }
118
+
119
+ /** True when `key` has a string in `language` (or the default language). */
120
+ function exists(key: string, language: string = defaultLanguage): boolean {
121
+ return find(resolveLanguage(language), key, undefined) !== undefined;
122
+ }
123
+
124
+ /** The raw entry at `key` in one language's dictionary, no fallback: a string, a subtree or undefined. */
125
+ function resource(language: string, key: string): string | Dictionary | undefined {
126
+ return lookup(dictionaries[language], key);
127
+ }
24
128
 
25
- /** Translates `key` ("errors.NOT_FOUND"), falling back to the default language, then `fallback`. */
26
- function t(
129
+ /** The request's language inside `detectLanguage`/`withLanguage`, else the default language. */
130
+ function currentLanguage(): string {
131
+ return storage.getStore() ?? defaultLanguage;
132
+ }
133
+
134
+ /** Runs `fn` with `currentLanguage()` set to `language` (for jobs, CLI, tests). */
135
+ function withLanguage<T>(language: string, fn: () => T): T {
136
+ return storage.run(resolveLanguage(language), fn);
137
+ }
138
+
139
+ /** The value for `language` from per-language values (e.g. `{ en: row.nameEn, ar: row.nameAr }`); empty falls back to the default language. */
140
+ function pick(
27
141
  language: string,
28
- key: string,
29
- fallback = key,
30
- vars: Record<string, string> = {},
142
+ values: Partial<Record<string, string | null | undefined>>,
31
143
  ): string {
32
- const text =
33
- lookup(dictionaries[language], key) ?? lookup(dictionaries[defaultLanguage], key) ?? fallback;
34
- return text.replace(/\{(\w+)\}/g, (_, name: string) => vars[name] ?? `{${name}}`);
144
+ return values[language] || values[defaultLanguage] || "";
35
145
  }
36
146
 
37
- /** ?lang, then the `lang` cookie, then Accept-Language; sets `c.var.language`. */
38
- const detectLanguage = createMiddleware<{ Variables: { language: string } }>(async (c, next) => {
39
- const candidates = [
40
- c.req.query("lang"),
41
- /(?:^|;\s*)lang=([^;]+)/.exec(c.req.header("cookie") ?? "")?.[1],
42
- ...(c.req.header("accept-language") ?? "")
43
- .split(",")
44
- .map((part) => part.split(";")[0]?.trim().toLowerCase().split("-")[0]),
45
- ];
46
- const language = candidates.find((l) => l && languages.includes(l)) ?? defaultLanguage;
147
+ function accepted(header: string): string | undefined {
148
+ const ranked = header
149
+ .split(",")
150
+ .map((part, index) => {
151
+ const [tag = "", ...params] = part.trim().toLowerCase().split(";");
152
+ const q = params.map((p) => /^\s*q=([\d.]+)/.exec(p)?.[1]).find(Boolean);
153
+ return { tag: tag.trim(), q: q === undefined ? 1 : Number(q), index };
154
+ })
155
+ .filter((entry) => entry.tag && entry.q > 0)
156
+ .sort((a, b) => b.q - a.q || a.index - b.index);
157
+ for (const { tag } of ranked) {
158
+ const match = languages.find((l) => l.toLowerCase() === tag) ?? tag.split("-")[0];
159
+ if (match && supported.has(match)) return match;
160
+ }
161
+ return undefined;
162
+ }
163
+
164
+ function apply(c: Context, language: string, next: () => Promise<void>) {
47
165
  c.set("language", language);
48
166
  c.header("content-language", language);
49
- await next();
167
+ return storage.run(language, next);
168
+ }
169
+
170
+ function explicitQuery(c: Context): string | undefined {
171
+ const value = queryKey ? c.req.query(queryKey) : undefined;
172
+ return value && supported.has(value) ? value : undefined;
173
+ }
174
+
175
+ /** ?lang, then the `lang` cookie, then Accept-Language; sets `c.var.language` and `currentLanguage()`. */
176
+ const detectLanguage = createMiddleware<{ Variables: { language: string } }>(async (c, next) => {
177
+ const fromCookie = cookieName ? getCookie(c, cookieName) : undefined;
178
+ const language =
179
+ explicitQuery(c) ??
180
+ (fromCookie && supported.has(fromCookie) ? fromCookie : undefined) ??
181
+ (detection.header === false ? undefined : accepted(c.req.header("accept-language") ?? "")) ??
182
+ defaultLanguage;
183
+ if (detection.persist && cookieName && fromCookie !== language)
184
+ setCookie(c, cookieName, language, detection.persist);
185
+ await apply(c, language, next);
50
186
  }) as unknown as MiddlewareHandler<Env>;
51
187
  // typed as adding nothing to the env, so it mounts on any app whose env has `language`
52
188
 
53
- return { languages, t, detectLanguage };
189
+ /**
190
+ * Replaces the detected language with `preferred(c)` (e.g. the signed-in user's stored
191
+ * preference) when it is supported, unless the request asked for one with the query
192
+ * parameter. Mount after `detectLanguage` and after whatever sets the user.
193
+ */
194
+ function preferLanguage(
195
+ preferred: (c: Context) => string | null | undefined | Promise<string | null | undefined>,
196
+ ): MiddlewareHandler<Env> {
197
+ return async (c, next) => {
198
+ if (explicitQuery(c)) return next();
199
+ const language = await preferred(c);
200
+ if (!language || !supported.has(language)) return next();
201
+ await apply(c, language, next);
202
+ };
203
+ }
204
+
205
+ return {
206
+ languages,
207
+ defaultLanguage,
208
+ t,
209
+ translator,
210
+ exists,
211
+ resource,
212
+ resolveLanguage,
213
+ currentLanguage,
214
+ withLanguage,
215
+ pick,
216
+ detectLanguage,
217
+ preferLanguage,
218
+ };
54
219
  }
220
+
221
+ export type I18n = ReturnType<typeof createI18n>;
package/src/load.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Dictionary } from "./index.ts";
4
+
5
+ export interface LoadDictionariesOptions {
6
+ /** holds `<language>/<namespace>.json`, e.g. `${process.cwd()}/locales` */
7
+ dir: string;
8
+ /** default: every sub-directory of `dir` */
9
+ languages?: readonly string[];
10
+ /** default: every `*.json` file found in any language */
11
+ namespaces?: readonly string[];
12
+ /** a namespace file one language lacks; that namespace is `{}` there */
13
+ onMissing?: (language: string, namespace: string) => void;
14
+ }
15
+
16
+ async function list(dir: string, pick: (name: string, isDir: boolean) => string | undefined) {
17
+ const entries = await readdir(dir, { withFileTypes: true });
18
+ return entries.flatMap((e) => pick(e.name, e.isDirectory()) ?? []).sort();
19
+ }
20
+
21
+ /**
22
+ * Reads `<dir>/<language>/<namespace>.json` into `{ [language]: { [namespace]: … } }`, so
23
+ * `t(language, "namespace:key")` finds them. Namespaces are discovered from the files.
24
+ */
25
+ export async function loadDictionaries(
26
+ options: LoadDictionariesOptions,
27
+ ): Promise<Record<string, Dictionary>> {
28
+ const languages =
29
+ options.languages ?? (await list(options.dir, (name, isDir) => (isDir ? name : undefined)));
30
+ const files = new Map<string, string[]>();
31
+ for (const language of languages) {
32
+ const names = await list(join(options.dir, language), (name, isDir) =>
33
+ !isDir && name.endsWith(".json") ? name.slice(0, -".json".length) : undefined,
34
+ ).catch(() => []);
35
+ files.set(language, names);
36
+ }
37
+ const namespaces = options.namespaces ?? [...new Set([...files.values()].flat())].sort();
38
+ const result: Record<string, Dictionary> = {};
39
+ for (const language of languages) {
40
+ const dictionary: Dictionary = {};
41
+ for (const namespace of namespaces) {
42
+ if (!files.get(language)?.includes(namespace)) {
43
+ options.onMissing?.(language, namespace);
44
+ dictionary[namespace] = {};
45
+ continue;
46
+ }
47
+ const text = await readFile(join(options.dir, language, `${namespace}.json`), "utf8");
48
+ dictionary[namespace] = JSON.parse(text) as Dictionary;
49
+ }
50
+ result[language] = dictionary;
51
+ }
52
+ return result;
53
+ }
package/src/zod.ts ADDED
@@ -0,0 +1,66 @@
1
+ import type { I18n } from "./index.ts";
2
+
3
+ /** The fields of a zod issue the messages use; zod's own issue type is assignable to it. */
4
+ export interface ZodIssueLike {
5
+ code?: string;
6
+ input?: unknown;
7
+ expected?: unknown;
8
+ minimum?: unknown;
9
+ maximum?: unknown;
10
+ values?: readonly unknown[];
11
+ }
12
+
13
+ export type ZodMessageKind =
14
+ | "required"
15
+ | "invalid_type"
16
+ | "too_small"
17
+ | "too_big"
18
+ | "invalid_format"
19
+ | "invalid_value"
20
+ | "custom";
21
+
22
+ export interface ZodErrorMapOptions {
23
+ /** key prefix; a message's key is `${prefix}${kind}` (default "validation.") */
24
+ prefix?: string;
25
+ /** full keys for some kinds, overriding the prefix */
26
+ keys?: Partial<Record<ZodMessageKind, string>>;
27
+ }
28
+
29
+ function describe(issue: ZodIssueLike): [ZodMessageKind, Record<string, unknown>] | undefined {
30
+ switch (issue.code) {
31
+ case "invalid_type":
32
+ return issue.input === undefined
33
+ ? ["required", {}]
34
+ : ["invalid_type", { expected: issue.expected, received: typeof issue.input }];
35
+ case "too_small":
36
+ return ["too_small", { minimum: String(issue.minimum) }];
37
+ case "too_big":
38
+ return ["too_big", { maximum: String(issue.maximum) }];
39
+ case "invalid_format":
40
+ return ["invalid_format", {}];
41
+ case "invalid_value":
42
+ return ["invalid_value", { options: (issue.values ?? []).map(String).join(", ") }];
43
+ case "custom":
44
+ return ["custom", {}];
45
+ default:
46
+ return undefined;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * A zod error map giving messages in `i18n.currentLanguage()` (the request's language under
52
+ * `detectLanguage`). Register it once: `z.config({ customError: zodErrorMap(i18n) })`.
53
+ * Kinds without a translation keep zod's own message.
54
+ */
55
+ export function zodErrorMap(i18n: I18n, options: ZodErrorMapOptions = {}) {
56
+ const prefix = options.prefix ?? "validation.";
57
+ return (issue: ZodIssueLike): { message: string } | undefined => {
58
+ const described = describe(issue);
59
+ if (!described) return undefined;
60
+ const [kind, vars] = described;
61
+ const key = options.keys?.[kind] ?? `${prefix}${kind}`;
62
+ const language = i18n.currentLanguage();
63
+ if (!i18n.exists(key, language)) return undefined;
64
+ return { message: i18n.t(language, key, key, vars) };
65
+ };
66
+ }