From de5d3f12a3495a8fdbaf95cd5acafcba60f8e77b Mon Sep 17 00:00:00 2001 From: Aiji Uejima Date: Tue, 28 Feb 2023 10:51:52 +0900 Subject: [PATCH 1/3] feat: support plurals format message --- README.md | 78 ++++++++++++++++------ packages/core/src/index.ts | 12 ++-- packages/core/tests/makeZodI18nMap.test.ts | 31 +++++++++ 3 files changed, 98 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index bd60962..0a57234 100644 --- a/README.md +++ b/README.md @@ -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; }; }; @@ -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}}` and `{{minimum}}` 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`) @@ -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({ @@ -179,7 +221,7 @@ i18next.init({ }, }, form: { - group: { + paths: { userName: "User's name", } } @@ -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. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cad8ed1..aadb081 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -177,6 +177,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 @@ -186,8 +188,8 @@ 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, @@ -195,6 +197,8 @@ export const makeZodI18nMap: MakeZodI18nMap = (option) => (issue, ctx) => { ); 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 @@ -204,8 +208,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, diff --git a/packages/core/tests/makeZodI18nMap.test.ts b/packages/core/tests/makeZodI18nMap.test.ts index 301ad4e..f9c5232 100644 --- a/packages/core/tests/makeZodI18nMap.test.ts +++ b/packages/core/tests/makeZodI18nMap.test.ts @@ -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( From 65d848a83674c292c56b057c698875be39970321 Mon Sep 17 00:00:00 2001 From: Aiji Uejima Date: Tue, 28 Feb 2023 11:03:42 +0900 Subject: [PATCH 2/3] chore: update example --- .../public/locales/en/zod.json | 34 ++++++++++++++++--- packages/core/src/index.ts | 1 + 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/examples/with-next-i18next/public/locales/en/zod.json b/examples/with-next-i18next/public/locales/en/zod.json index 8459e61..d35978d 100644 --- a/examples/with-next-i18next/public/locales/en/zod.json +++ b/examples/with-next-i18next/public/locales/en/zod.json @@ -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}}", @@ -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}}", @@ -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}}", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aadb081..3774889 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, From 18579b4231daa0af64ec7abbd7fffae5ab037007 Mon Sep 17 00:00:00 2001 From: Aiji Uejima Date: Tue, 28 Feb 2023 11:05:20 +0900 Subject: [PATCH 3/3] docs: update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0a57234..75d237b 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ z.string().parse(1) // => it is expected to provide string but you provided numb ### Plurals -Messages using `{{maximum}}` and `{{minimum}}` can be converted to the plural form. +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