diff --git a/src/i18n/serializer.test.ts b/src/i18n/serializer.test.ts new file mode 100644 index 00000000..1016a935 --- /dev/null +++ b/src/i18n/serializer.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createI18nInstance } from "./config.js"; +import { serializeI18nForHydration, hydrateI18nFromWindow } from "./serializer.js"; + +describe("serializeI18nForHydration", () => { + it("returns a JSON string containing locale and resources", async () => { + const i18n = await createI18nInstance({ locale: "ru" }); + const json = serializeI18nForHydration(i18n); + const parsed = JSON.parse(json); + expect(parsed.locale).toBe("ru"); + expect(parsed.resources).toBeDefined(); + expect(parsed.resources.common).toBeDefined(); + expect(parsed.resources.common["BOARD"]).toBeDefined(); + }); +}); + +describe("hydrateI18nFromWindow", () => { + afterEach(() => { + // Clean up the global + if (typeof globalThis !== "undefined") { + delete (globalThis as Record).__I18N__; + } + }); + + it("roundtrips: serialize → inject into globalThis.__I18N__ → hydrate", async () => { + const original = await createI18nInstance({ locale: "ru" }); + const json = serializeI18nForHydration(original); + const payload = JSON.parse(json); + + // Simulate the SSR injection into the global scope + (globalThis as Record).__I18N__ = payload; + + const hydrated = await hydrateI18nFromWindow(); + expect(hydrated.language).toBe("ru"); + expect(hydrated.t("BOARD.DEPARTURE")).toBe(original.t("BOARD.DEPARTURE")); + }); + + it("throws if __I18N__ is not set", async () => { + await expect(hydrateI18nFromWindow()).rejects.toThrow(/__I18N__/); + }); +}); diff --git a/src/i18n/serializer.ts b/src/i18n/serializer.ts new file mode 100644 index 00000000..41371dc8 --- /dev/null +++ b/src/i18n/serializer.ts @@ -0,0 +1,61 @@ +import type i18next from "i18next"; +import { createI18nInstance } from "./config.js"; +import { isLanguage } from "./resolver.js"; + +interface I18nPayload { + locale: string; + resources: Record>; +} + +/** + * Serializes the loaded locale bundle from an i18next instance into a JSON + * string suitable for injection into the SSR HTML under `window.__I18N__`. + */ +export function serializeI18nForHydration(i18n: typeof i18next): string { + const locale = i18n.language; + const resources: Record> = {}; + + const namespaces = Array.isArray(i18n.options.ns) + ? i18n.options.ns + : ([i18n.options.ns].filter(Boolean) as string[]); + + for (const ns of namespaces) { + const bundle = i18n.getResourceBundle(locale, ns) as + | Record + | undefined; + if (bundle) { + resources[ns] = bundle; + } + } + + const payload: I18nPayload = { locale, resources }; + return JSON.stringify(payload); +} + +/** + * Rehydrates an i18next instance from the SSR-injected `window.__I18N__` payload. + * Called once on the client before React hydration begins. + */ +export async function hydrateI18nFromWindow(): Promise { + const raw = (globalThis as Record).__I18N__ as + | I18nPayload + | undefined; + if (!raw) { + throw new Error( + "Cannot hydrate i18n: globalThis.__I18N__ is not set. " + + "Ensure the SSR payload includes the serialized i18n data.", + ); + } + + const locale = raw.locale; + if (!isLanguage(locale)) { + throw new Error( + `Cannot hydrate i18n: invalid locale "${locale}" in __I18N__ payload.`, + ); + } + + return createI18nInstance({ + locale, + initialResources: raw.resources, + }); +}