diff --git a/packages/core/src/locales.ts b/packages/core/src/locales.ts index c79b36cb59..21ff0c85ea 100644 --- a/packages/core/src/locales.ts +++ b/packages/core/src/locales.ts @@ -3,6 +3,7 @@ import az from "./locales/az.js"; import cs from "./locales/cs.js"; import en from "./locales/en.js"; import es from "./locales/es.js"; +import ru from "./locales/ru.js"; import tr from "./locales/tr.js"; import id from "./locales/id.js"; import it from "./locales/it.js"; @@ -17,4 +18,4 @@ import fr from "./locales/fr.js"; import ja from "./locales/ja.js"; import pt from "./locales/pt.js"; -export { ar, az, cs, es, en, fi, he, hu, id, it, pt, tr, ja, fr, pl, ua, vi, zhCN }; +export { ar, az, cs, es, en, fi, fr, he, hu, id, it, ja, pl, pt, ru, tr, ua, vi, zhCN }; diff --git a/packages/core/src/locales/ru.ts b/packages/core/src/locales/ru.ts new file mode 100644 index 0000000000..6f0119db77 --- /dev/null +++ b/packages/core/src/locales/ru.ts @@ -0,0 +1,185 @@ +import type { $ZodStringFormats } from "../checks.js"; +import type * as errors from "../errors.js"; +import * as util from "../util.js"; + +function getRussianPlural(count: number, one: string, few: string, many: string): string { + const absCount = Math.abs(count); + const lastDigit = absCount % 10; + const lastTwoDigits = absCount % 100; + + if (lastTwoDigits >= 11 && lastTwoDigits <= 19) { + return many; + } + + if (lastDigit === 1) { + return one; + } + + if (lastDigit >= 2 && lastDigit <= 4) { + return few; + } + + return many; +} + +interface RussianSizable { + unit: { + one: string; + few: string; + many: string; + }; + verb: string; +} + +const Sizable: Record = { + string: { + unit: { + one: "символ", + few: "символа", + many: "символов", + }, + verb: "иметь", + }, + file: { + unit: { + one: "байт", + few: "байта", + many: "байт", + }, + verb: "иметь", + }, + array: { + unit: { + one: "элемент", + few: "элемента", + many: "элементов", + }, + verb: "иметь", + }, + set: { + unit: { + one: "элемент", + few: "элемента", + many: "элементов", + }, + verb: "иметь", + }, +}; + +function getSizing(origin: string): RussianSizable | null { + return Sizable[origin] ?? null; +} + +export const parsedType = (data: any): string => { + const t = typeof data; + + switch (t) { + case "number": { + return Number.isNaN(data) ? "NaN" : "число"; + } + case "object": { + if (Array.isArray(data)) { + return "массив"; + } + if (data === null) { + return "null"; + } + + if (Object.getPrototypeOf(data) !== Object.prototype && data.constructor) { + return data.constructor.name; + } + } + } + return t; +}; + +const Nouns: { + [k in $ZodStringFormats | (string & {})]?: string; +} = { + regex: "ввод", + email: "email адрес", + url: "URL", + emoji: "эмодзи", + uuid: "UUID", + uuidv4: "UUIDv4", + uuidv6: "UUIDv6", + nanoid: "nanoid", + guid: "GUID", + cuid: "cuid", + cuid2: "cuid2", + ulid: "ULID", + xid: "XID", + ksuid: "KSUID", + datetime: "ISO дата и время", + date: "ISO дата", + time: "ISO время", + duration: "ISO длительность", + ipv4: "IPv4 адрес", + ipv6: "IPv6 адрес", + cidrv4: "IPv4 диапазон", + cidrv6: "IPv6 диапазон", + base64: "строка в формате base64", + base64url: "строка в формате base64url", + json_string: "JSON строка", + e164: "номер E.164", + jwt: "JWT", + template_literal: "ввод", +}; + +const error: errors.$ZodErrorMap = (issue) => { + switch (issue.code) { + case "invalid_type": + return `Неверный ввод: ожидалось ${issue.expected}, получено ${parsedType(issue.input)}`; + case "invalid_value": + if (issue.values.length === 1) return `Неверный ввод: ожидалось ${util.stringifyPrimitive(issue.values[0])}`; + return `Неверный вариант: ожидалось одно из ${util.joinValues(issue.values, "|")}`; + case "too_big": { + const adj = issue.inclusive ? "<=" : "<"; + const sizing = getSizing(issue.origin); + if (sizing) { + const maxValue = Number(issue.maximum); + const unit = getRussianPlural(maxValue, sizing.unit.one, sizing.unit.few, sizing.unit.many); + return `Слишком большое значение: ожидалось, что ${issue.origin ?? "значение"} будет иметь ${adj}${issue.maximum.toString()} ${unit}`; + } + return `Слишком большое значение: ожидалось, что ${issue.origin ?? "значение"} будет ${adj}${issue.maximum.toString()}`; + } + case "too_small": { + const adj = issue.inclusive ? ">=" : ">"; + const sizing = getSizing(issue.origin); + if (sizing) { + const minValue = Number(issue.minimum); + const unit = getRussianPlural(minValue, sizing.unit.one, sizing.unit.few, sizing.unit.many); + return `Слишком маленькое значение: ожидалось, что ${issue.origin} будет иметь ${adj}${issue.minimum.toString()} ${unit}`; + } + return `Слишком маленькое значение: ожидалось, что ${issue.origin} будет ${adj}${issue.minimum.toString()}`; + } + case "invalid_format": { + const _issue = issue as errors.$ZodStringFormatIssues; + if (_issue.format === "starts_with") return `Неверная строка: должна начинаться с "${_issue.prefix}"`; + if (_issue.format === "ends_with") return `Неверная строка: должна заканчиваться на "${_issue.suffix}"`; + if (_issue.format === "includes") return `Неверная строка: должна содержать "${_issue.includes}"`; + if (_issue.format === "regex") return `Неверная строка: должна соответствовать шаблону ${_issue.pattern}`; + return `Неверный ${Nouns[_issue.format] ?? issue.format}`; + } + case "not_multiple_of": + return `Неверное число: должно быть кратным ${issue.divisor}`; + case "unrecognized_keys": + return `Нераспознанн${issue.keys.length > 1 ? "ые" : "ый"} ключ${issue.keys.length > 1 ? "и" : ""}: ${util.joinValues(issue.keys, ", ")}`; + case "invalid_key": + return `Неверный ключ в ${issue.origin}`; + case "invalid_union": + return "Неверные входные данные"; + case "invalid_element": + return `Неверное значение в ${issue.origin}`; + default: + return `Неверные входные данные`; + } +}; + +export { error }; + +export default function (): { localeError: errors.$ZodErrorMap } { + return { + localeError: error, + }; +} diff --git a/packages/core/tests/locales/ru.test.ts b/packages/core/tests/locales/ru.test.ts new file mode 100644 index 0000000000..8abe6d7276 --- /dev/null +++ b/packages/core/tests/locales/ru.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import ru from "../../src/locales/ru.js"; + +describe("Russian localization", () => { + const { localeError } = ru(); + + describe("pluralization rules", () => { + for (const { type, cases } of TEST_CASES) { + describe(`${type} pluralization`, () => { + for (const { count, expected } of cases) { + it(`correctly pluralizes ${count} ${type}`, () => { + const error = localeError({ + code: "too_small", + minimum: count, + type: "number", + inclusive: true, + path: [], + origin: type, + input: count - 1, + }); + + expect(error).toContain(expected); + }); + } + }); + } + + it("handles negative numbers correctly", () => { + const error = localeError({ + code: "too_small", + minimum: -2, + type: "number", + inclusive: true, + path: [], + origin: "array", + input: -3, + }); + + expect(error).toContain("-2 элемента"); + }); + + it("handles zero correctly", () => { + const error = localeError({ + code: "too_small", + minimum: 0, + type: "number", + inclusive: true, + path: [], + origin: "array", + input: -1, + }); + + expect(error).toContain("0 элементов"); + }); + + it("handles bigint values correctly", () => { + const error = localeError({ + code: "too_small", + minimum: BigInt(21), + type: "number", + inclusive: true, + path: [], + origin: "array", + input: BigInt(20), + }); + + expect(error).toContain("21 элемент"); + }); + }); +}); + +const TEST_CASES = [ + { + type: "array", + cases: [ + { count: 1, expected: "1 элемент" }, + { count: 2, expected: "2 элемента" }, + { count: 5, expected: "5 элементов" }, + { count: 11, expected: "11 элементов" }, + { count: 21, expected: "21 элемент" }, + { count: 22, expected: "22 элемента" }, + { count: 25, expected: "25 элементов" }, + { count: 101, expected: "101 элемент" }, + { count: 111, expected: "111 элементов" }, + ], + }, + { + type: "set", + cases: [ + { count: 1, expected: "1 элемент" }, + { count: 2, expected: "2 элемента" }, + { count: 5, expected: "5 элементов" }, + { count: 11, expected: "11 элементов" }, + { count: 21, expected: "21 элемент" }, + { count: 22, expected: "22 элемента" }, + { count: 25, expected: "25 элементов" }, + { count: 101, expected: "101 элемент" }, + { count: 111, expected: "111 элементов" }, + ], + }, + { + type: "string", + cases: [ + { count: 1, expected: "1 символ" }, + { count: 2, expected: "2 символа" }, + { count: 5, expected: "5 символов" }, + { count: 11, expected: "11 символов" }, + { count: 21, expected: "21 символ" }, + { count: 22, expected: "22 символа" }, + { count: 25, expected: "25 символов" }, + ], + }, + { + type: "file", + cases: [ + { count: 0, expected: "0 байт" }, + { count: 1, expected: "1 байт" }, + { count: 2, expected: "2 байта" }, + { count: 5, expected: "5 байт" }, + { count: 11, expected: "11 байт" }, + { count: 21, expected: "21 байт" }, + { count: 22, expected: "22 байта" }, + { count: 25, expected: "25 байт" }, + { count: 101, expected: "101 байт" }, + { count: 110, expected: "110 байт" }, + ], + }, +] as const; diff --git a/packages/docs/content/error-customization.mdx b/packages/docs/content/error-customization.mdx index 2e0cd2fe10..51b07247f0 100644 --- a/packages/docs/content/error-customization.mdx +++ b/packages/docs/content/error-customization.mdx @@ -358,6 +358,8 @@ The following locales are available: - `ar` — Arabic - `az` — Azerbaijani - `en` — English +- `es` — Spanish +- `ru` — Russian - `tr` — Türkçe - `it` — Italian - `cs` - Czech