diff --git a/src/index.ts b/src/index.ts index c3e51b0..1a8e720 100644 --- a/src/index.ts +++ b/src/index.ts @@ -204,9 +204,14 @@ export function createI18n< throw new Error("The hydrated i18n catalog must match its locale."); } + const requestedDirection = hydrated + ? hydrated.dir + : props.dir === undefined + ? "ltr" + : props.dir; const value = Object.freeze({ locale, - dir: assertDirection(hydrated?.dir ?? props.dir ?? "ltr"), + dir: assertDirection(requestedDirection), catalog: locale, }); @@ -243,8 +248,3 @@ export function createI18n< }, }); } -/** Format a message in an explicitly selected locale. */ -/** Read the active locale. */ -/** Read the active locale's text direction. */ -/** Read the active catalog identifier. */ -/** Serialize the active locale state for hydration. */ diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index a74321b..e05e73c 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -116,6 +116,85 @@ describe("createI18n", () => { ).toThrow("Invalid i18n text direction: sideways"); }); + it.each(["", "auto", "RTL", "left-to-right", null, 0])( + "should reject additional initial and hydrated direction value %j", + (dir) => { + expect(() => + renderToStringSync(() => + messages.Scope({ locale: "en", dir, children: () => messages.direction() } as never), + ), + ).toThrow("Invalid i18n text direction"); + expect(() => + renderToStringSync(() => + messages.Scope({ + hydration: { version: 1, locale: "en", catalog: "en", dir }, + children: () => messages.direction(), + } as never), + ), + ).toThrow("Invalid i18n text direction"); + }, + ); + + it("should switch locales after hydration without retaining direction state", () => { + const hydrated = renderToStringSync(() => + messages.Scope({ + hydration: { version: 1, locale: "ar", catalog: "ar", dir: "rtl" }, + children: () => `${messages.locale()}:${messages.direction()}`, + }), + ); + const switched = renderToStringSync(() => + messages.Scope({ + locale: "en", + children: () => `${messages.locale()}:${messages.direction()}`, + }), + ); + expect(hydrated).toBe("ar:rtl"); + expect(switched).toBe("en:ltr"); + }); + + it("should support complex plural categories through application-owned message functions", () => { + const plurals = createI18n("ar", { + ar: { + items: (count: number) => new Intl.PluralRules("ar").select(count), + }, + pl: { + items: (count: number) => new Intl.PluralRules("pl").select(count), + }, + }); + expect([0, 1, 2, 3, 11, 100].map((count) => plurals.format("ar", "items", count))).toEqual([ + "zero", + "one", + "two", + "few", + "many", + "other", + ]); + expect([1, 2, 5, 1.5].map((count) => plurals.format("pl", "items", count))).toEqual([ + "one", + "few", + "many", + "other", + ]); + }); + + it("should preserve placeholder-shaped interpolation values and mixed-direction text", () => { + const literal = "Ada {name} {{total}}"; + expect(messages.format("en", "greeting", { name: literal })).toBe(`Hello, ${literal}`); + expect(messages.format("ar", "greeting", { name: "Ada 123" })).toBe("مرحبا، Ada 123"); + }); + + it("should reject absent, partial, and missing-key catalog access explicitly", () => { + expect(() => createI18n("en", {} as never)).toThrow("at least one catalog"); + expect(() => + createI18n("en", { + en: { first: () => "first", second: () => "second" }, + fr: { first: () => "premier" } as never, + }), + ).toThrow("missing second"); + const unsafeFormat = messages.format as unknown as (locale: string, key: string) => string; + expect(() => unsafeFormat("en", "missing")).toThrow("Missing i18n message: en.missing"); + }); + it("should reject invalid catalogs for untyped callers and own frozen copies", () => { expect(() => createI18n("en", { diff --git a/tests/types.ts b/tests/types.ts index 6dd37ec..aa8d57b 100644 --- a/tests/types.ts +++ b/tests/types.ts @@ -1,4 +1,12 @@ -import { createI18n } from "../src/index"; +import { + createI18n, + type Catalog, + type CatalogMessage, + type I18n, + type I18nHydration, + type I18nScopeProps, + type TextDirection, +} from "../src/index"; const i18n = createI18n("en", { en: { @@ -14,6 +22,19 @@ const i18n = createI18n("en", { i18n.text("greeting", { name: "Ada" }); i18n.format("fr", "count", 2); +const direction: TextDirection = "rtl"; +const message: CatalogMessage<[string]> = (value) => value; +const catalog: Catalog = { message }; +const service: I18n = i18n; +const hydration: I18nHydration<"en" | "fr"> = { + version: 1, + locale: "fr", + dir: direction, + catalog: "fr", +}; +const scopeProps: I18nScopeProps<"en" | "fr"> = { hydration }; +void [catalog, service, scopeProps]; + createI18n("en", { en: { greeting: (name: string) => name }, // @ts-expect-error every locale must contain every source key