Skip to content

Commit

Permalink
Merge pull request #73 from aiji42/plurals
Browse files Browse the repository at this point in the history
feat: support plurals format message
  • Loading branch information
aiji42 authored Feb 28, 2023
2 parents 59570ab + 18579b4 commit fb3f858
Show file tree
Hide file tree
Showing 4 changed files with 129 additions and 27 deletions.
78 changes: 59 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,6 @@ export type ZodI18nMapOption = {
t?: i18n["t"];
ns?: string | readonly string[]; // See: `Namespace`
handlePath?: { // See: `Handling object schema keys`
context?: string;
ns?: string | readonly string[];
keyPrefix?: string;
};
};
Expand Down Expand Up @@ -90,36 +88,81 @@ z.setErrorMap(makeZodI18nMap({ ns: 'formValidation' }))
z.string().parse(1) // => it is expected to provide string but you provided number
```

📝 **You can also specify multiple namespaces in an array.**

### Plurals

Messages using `{{maximum}}`, `{{minimum}}` or `{{keys}}` can be converted to the plural form.

Keys are i18next compliant. (https://www.i18next.com/translation-function/plurals)
```json
{
"exact_one": "String must contain exactly {{minimum}} character",
"exact_other": "String must contain exactly {{minimum}} characters",
}
```

```ts
import i18next from 'i18next'
import { z } from 'zod'
import { zodI18nMap } from "zod-i18n-map";

i18next.init({
lng: "en",
resources: {
en: {
zod: {
errors: {
too_big: {
string: {
exact_one:
"String must contain exactly {{maximum}} character",
exact_other:
"String must contain exactly {{maximum}} characters",
},
},
},
},
},
},
});

z.setErrorMap(zodI18nMap);

z.string().length(1).safeParse("abc"); // => String must contain exactly 1 character

z.string().length(5).safeParse("abcdefgh"); // => String must contain exactly 5 characters
```

### Custom errors

You can translate also custom errors, for example errors from refine.
You can translate also custom errors, for example errors from `refine`.

Create a key for the custom error in a namespace and add i18nKey to the refine second arg(see example)
Create a key for the custom error in a namespace and add `i18n` to the refine second arg(see example)

```ts
import i18next from 'i18next'
import { z } from 'zod'
import { makeZodI18nMap } from "zod-i18n-map";
import translation from 'zod-i18n-map/locales/en/zod.json'

i18next.init({
lng: 'en',
resources: {
en: {
my_custom_error_namespace: { // give the namespace a name
zod: translation,
custom: {
my_error_key: "Something terrible"
}
},
},
});

// use global error map
z.setErrorMap(makeZodI18nMap({ns: 'my_custom_error_namespace'}))
z.string().refine(() => false, { params: { i18n: 'my_error_key' } }).safeParse('')// => Something terrible
z.setErrorMap(makeZodI18nMap({ ns: ["zod", "custom"] }));

// you can use local error map
z.string().refine(() => false, { params: { i18n: 'my_error_key' } })
.safeParse('', {errorMap: makeZodI18nMap({ns: 'my_custom_error_namespace'})})// => Something terrible
z.string()
.refine(() => false, { params: { i18n: "my_error_key" } })
.safeParse(""); // => Something terrible
```

### Handling object schema keys (`handlePath`)
Expand Down Expand Up @@ -159,12 +202,11 @@ schema.parse({ userName: 1 }) // => User's name is expected string, received num
```

If `_with_path` is suffixed to the key of the message, that message will be adopted in the case of an object type schema.
If there is no message key with `_with_path`, fall back to the normal error message.
The suffix can be changed by specifying `handlePath.context`.
If there is no message key with `_with_path`, fall back to the normal error message.

Object schema keys can be handled in the message with `{{path}}`.
By preparing the translated data for the same key as the key in the object schema, the translated value will be output in `{{path}}`, otherwise the key will be output as is.
You can specify `handlePath.ns` to separate the namespace of translation data for `{{path}}`. Furthermore, it is possible to access nested translation data by specifying `handlePath.keyPrefix`.
It is possible to access nested translation data by specifying `handlePath.keyPrefix`.

```ts
i18next.init({
Expand All @@ -179,7 +221,7 @@ i18next.init({
},
},
form: {
group: {
paths: {
userName: "User's name",
}
}
Expand All @@ -188,15 +230,13 @@ i18next.init({
});

z.setErrorMap(zodI18nMap({
ns: ["zod", "form"],
handlePath: {
ns: "form",
keyPrefix: "group"
keyPrefix: "paths"
}
}));
```



## Translation Files
`zod-i18n-map` contains translation files for several locales.

Expand Down
34 changes: 30 additions & 4 deletions examples/with-next-i18next/public/locales/en/zod.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
"invalid_type_received_undefined": "Required",
"invalid_literal": "Invalid literal value, expected {{expected}}",
"unrecognized_keys": "Unrecognized key(s) in object: {{- keys}}",
"unrecognized_keys_one": "Unrecognized key in object: {{- keys}}",
"unrecognized_keys_other": "Unrecognized keys in object: {{- keys}}",
"invalid_union": "Invalid input",
"invalid_union_discriminator": "Invalid discriminator value. Expected {{- options}}",
"invalid_enum_value": "Invalid enum value. Expected {{- options}}, received {{received}}",
Expand All @@ -28,13 +30,25 @@
"too_small": {
"array": {
"inclusive": "Array must contain at least {{minimum}} element(s)",
"not_inclusive": "Array must contain more than {{minimum}} element(s)"
"inclusive_one": "Array must contain at least {{minimum}} element",
"inclusive_other": "Array must contain at least {{minimum}} elements",
"not_inclusive": "Array must contain more than {{minimum}} element(s)",
"not_inclusive_one": "Array must contain more than {{minimum}} element",
"not_inclusive_other": "Array must contain more than {{minimum}} elements"
},
"string": {
"inclusive": "String must contain at least {{minimum}} character(s)",
"inclusive_one": "String must contain at least {{minimum}} character",
"inclusive_other": "String must contain at least {{minimum}} characters",
"inclusive_with_path": "{{path}} must contain at least {{minimum}} character(s)",
"inclusive_with_path_one": "{{path}} must contain at least {{minimum}} character",
"inclusive_with_path_other": "{{path}} must contain at least {{minimum}} characters",
"not_inclusive": "String must contain over {{minimum}} character(s)",
"not_inclusive_with_path": "{{path}} must contain over {{minimum}} character(s)"
"not_inclusive_one": "String must contain over {{minimum}} character",
"not_inclusive_other": "String must contain over {{minimum}} characters",
"not_inclusive_with_path": "{{path}} must contain over {{minimum}} character(s)",
"not_inclusive_with_path_one": "{{path}} must contain over {{minimum}} character",
"not_inclusive_with_path_other": "{{path}} must contain over {{minimum}} characters"
},
"number": {
"inclusive": "Number must be greater than or equal to {{minimum}}",
Expand All @@ -54,13 +68,25 @@
"too_big": {
"array": {
"inclusive": "Array must contain at most {{maximum}} element(s)",
"not_inclusive": "Array must contain less than {{maximum}} element(s)"
"inclusive_one": "Array must contain at most {{maximum}} element",
"inclusive_other": "Array must contain at most {{maximum}} elements",
"not_inclusive": "Array must contain less than {{maximum}} element(s)",
"not_inclusive_one": "Array must contain less than {{maximum}} element",
"not_inclusive_other": "Array must contain less than {{maximum}} elements"
},
"string": {
"inclusive": "String must contain at most {{maximum}} character(s)",
"inclusive_one": "String must contain at most {{maximum}} character",
"inclusive_other": "String must contain at most {{maximum}} characters",
"inclusive_with_path": "{{path}} must contain at most {{maximum}} character(s)",
"inclusive_with_path_one": "{{path}} must contain at most {{maximum}} character",
"inclusive_with_path_other": "{{path}} must contain at most {{maximum}} characters",
"not_inclusive": "String must contain under {{maximum}} character(s)",
"not_inclusive_with_path": "{{path}} must contain under {{maximum}} character(s)"
"not_inclusive_one": "String must contain under {{maximum}} character",
"not_inclusive_other": "String must contain under {{maximum}} characters",
"not_inclusive_with_path": "{{path}} must contain under {{maximum}} character(s)",
"not_inclusive_with_path_one": "{{path}} must contain under {{maximum}} character",
"not_inclusive_with_path_other": "{{path}} must contain under {{maximum}} characters"
},
"number": {
"inclusive": "Number must be less than or equal to {{maximum}}",
Expand Down
13 changes: 9 additions & 4 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export const makeZodI18nMap: MakeZodI18nMap = (option) => (issue, ctx) => {
case ZodIssueCode.unrecognized_keys:
message = t("errors.unrecognized_keys", {
keys: joinValues(issue.keys, ", "),
count: issue.keys.length,
ns,
defaultValue: message,
...path,
Expand Down Expand Up @@ -177,6 +178,8 @@ export const makeZodI18nMap: MakeZodI18nMap = (option) => (issue, ctx) => {
}
break;
case ZodIssueCode.too_small:
const minimum =
issue.type === "date" ? new Date(issue.minimum) : issue.minimum;
message = t(
`errors.too_small.${issue.type}.${
issue.exact
Expand All @@ -186,15 +189,17 @@ export const makeZodI18nMap: MakeZodI18nMap = (option) => (issue, ctx) => {
: "not_inclusive"
}`,
{
minimum:
issue.type === "date" ? new Date(issue.minimum) : issue.minimum,
minimum,
count: typeof minimum === "number" ? minimum : undefined,
ns,
defaultValue: message,
...path,
}
);
break;
case ZodIssueCode.too_big:
const maximum =
issue.type === "date" ? new Date(issue.maximum) : issue.maximum;
message = t(
`errors.too_big.${issue.type}.${
issue.exact
Expand All @@ -204,8 +209,8 @@ export const makeZodI18nMap: MakeZodI18nMap = (option) => (issue, ctx) => {
: "not_inclusive"
}`,
{
maximum:
issue.type === "date" ? new Date(issue.maximum) : issue.maximum,
maximum,
count: typeof maximum === "number" ? maximum : undefined,
ns,
defaultValue: message,
...path,
Expand Down
31 changes: 31 additions & 0 deletions packages/core/tests/makeZodI18nMap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,37 @@ describe("Handling key of object schema", () => {
});
});

describe("plurals", () => {
test("separate messages for singular and plural", async () => {
await i18next.init({
lng: "en",
resources: {
en: {
zod: {
errors: {
too_big: {
string: {
exact_one:
"String must contain exactly {{maximum}} character",
exact_other:
"String must contain exactly {{maximum}} characters",
},
},
},
},
},
},
});
z.setErrorMap(makeZodI18nMap());
expect(getErrorMessage(z.string().length(1).safeParse("abc"))).toEqual(
"String must contain exactly 1 character"
);
expect(getErrorMessage(z.string().length(5).safeParse("abcdefgh"))).toEqual(
"String must contain exactly 5 characters"
);
});
});

describe("jsonStringifyReplacer", () => {
test("include bigint", async () => {
expect(
Expand Down

1 comment on commit fb3f858

@vercel
Copy link

@vercel vercel bot commented on fb3f858 Feb 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

zod-i18n – ./

zod-i18n-git-main-aiji42.vercel.app
zod-i18n-aiji42.vercel.app
zod-i18n.vercel.app

Please sign in to comment.