@tatara/i18n 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +34 -2
- package/dist/index.d.mts +98 -0
- package/dist/index.mjs +234 -0
- package/package.json +42 -3
- package/src/enum-labels.ts +27 -0
- package/src/index.ts +221 -0
- package/src/load.ts +53 -0
- package/src/zod.ts +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mohammed Bageri
|
|
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
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tatara/i18n
|
|
2
2
|
|
|
3
|
-
|
|
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
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
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
|
|
56
|
+
//#region src/index.d.ts
|
|
57
|
+
export type Dictionary = {
|
|
58
|
+
[key: string]: string | Dictionary;
|
|
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
|
+
}
|
|
76
|
+
export interface I18nOptions {
|
|
77
|
+
/** language code → strings, e.g. { en, ar } from src/locales, or `loadDictionaries()` */
|
|
78
|
+
dictionaries: Record<string, Dictionary>;
|
|
79
|
+
defaultLanguage: string;
|
|
80
|
+
detection?: LanguageDetectionOptions;
|
|
81
|
+
}
|
|
82
|
+
export declare function createI18n({ dictionaries, defaultLanguage, detection }: I18nOptions): {
|
|
83
|
+
languages: 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;
|
|
93
|
+
detectLanguage: MiddlewareHandler<Env>;
|
|
94
|
+
preferLanguage: (preferred: (c: Context) => string | null | undefined | Promise<string | null | undefined>) => MiddlewareHandler<Env>;
|
|
95
|
+
};
|
|
96
|
+
export type I18n = ReturnType<typeof createI18n>;
|
|
97
|
+
//#endregion
|
|
98
|
+
export type { LoadDictionariesOptions, ZodErrorMapOptions, ZodIssueLike, ZodMessageKind };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { getCookie, setCookie } from "hono/cookie";
|
|
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
|
|
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
|
+
}
|
|
97
|
+
function lookup(dict, key) {
|
|
98
|
+
let node = dict;
|
|
99
|
+
for (const part of path(key)) {
|
|
100
|
+
if (!node || typeof node === "string") return void 0;
|
|
101
|
+
node = node[part];
|
|
102
|
+
}
|
|
103
|
+
return node;
|
|
104
|
+
}
|
|
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 = {} }) {
|
|
113
|
+
const languages = Object.keys(dictionaries);
|
|
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`. */
|
|
146
|
+
function t(language, key, fallback = key, vars = {}) {
|
|
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
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
languages,
|
|
220
|
+
defaultLanguage,
|
|
221
|
+
t,
|
|
222
|
+
translator,
|
|
223
|
+
exists,
|
|
224
|
+
resource,
|
|
225
|
+
resolveLanguage,
|
|
226
|
+
currentLanguage,
|
|
227
|
+
withLanguage,
|
|
228
|
+
pick,
|
|
229
|
+
detectLanguage,
|
|
230
|
+
preferLanguage
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
//#endregion
|
|
234
|
+
export { createI18n, loadDictionaries, withEnumLabels, withEnumLabelsAll, zodErrorMap };
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tatara/i18n",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "tatara i18n: dictionaries, t(), plurals, language detection for Hono and localised zod messages",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.mts",
|
|
11
|
+
"import": "./dist/index.mjs"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"src",
|
|
17
|
+
"!src/**/*.test.ts"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=24"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"hono": "^4.13.13"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"hono": "^4.13.13",
|
|
27
|
+
"zod": "^4.6.7"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/mohammed-bageri/tatara-cli.git",
|
|
35
|
+
"directory": "packages/i18n"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/mohammed-bageri/tatara-cli#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/mohammed-bageri/tatara-cli/issues"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsdown",
|
|
43
|
+
"typecheck": "tsc --noEmit"
|
|
44
|
+
}
|
|
6
45
|
}
|
|
@@ -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
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
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";
|
|
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";
|
|
16
|
+
|
|
17
|
+
export type Dictionary = { [key: string]: string | Dictionary };
|
|
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
|
+
|
|
36
|
+
export interface I18nOptions {
|
|
37
|
+
/** language code → strings, e.g. { en, ar } from src/locales, or `loadDictionaries()` */
|
|
38
|
+
dictionaries: Record<string, Dictionary>;
|
|
39
|
+
defaultLanguage: string;
|
|
40
|
+
detection?: LanguageDetectionOptions;
|
|
41
|
+
}
|
|
42
|
+
|
|
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)) {
|
|
55
|
+
if (!node || typeof node === "string") return undefined;
|
|
56
|
+
node = node[part];
|
|
57
|
+
}
|
|
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
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createI18n({ dictionaries, defaultLanguage, detection = {} }: I18nOptions) {
|
|
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
|
+
}
|
|
128
|
+
|
|
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(
|
|
141
|
+
language: string,
|
|
142
|
+
values: Partial<Record<string, string | null | undefined>>,
|
|
143
|
+
): string {
|
|
144
|
+
return values[language] || values[defaultLanguage] || "";
|
|
145
|
+
}
|
|
146
|
+
|
|
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>) {
|
|
165
|
+
c.set("language", language);
|
|
166
|
+
c.header("content-language", language);
|
|
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);
|
|
186
|
+
}) as unknown as MiddlewareHandler<Env>;
|
|
187
|
+
// typed as adding nothing to the env, so it mounts on any app whose env has `language`
|
|
188
|
+
|
|
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
|
+
};
|
|
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
|
+
}
|