plan/react-rewrite #1
@@ -0,0 +1,678 @@
|
||||
# Phase 1C — i18n Runtime + Locale Port Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ship the i18n runtime — `i18next` configured with ICU support, a URL locale resolver, 9 ported locale JSON files, SSR↔client hydration helpers, and a React context provider — so that downstream sub-plans (1F-layout, Phase 2 features) can render localized text with `t("BOARD.DEPARTURE")` in both SSR and client contexts without re-fetching locale data on hydration.
|
||||
|
||||
**Architecture:** `i18next` with `i18next-icu` as the ICU MessageFormat backend. One namespace (`common`) per language. `src/i18n/resolver.ts` determines the locale from the URL prefix. `src/i18n/config.ts` creates a request-scoped `i18next` instance loaded with one locale's JSON. `src/i18n/serializer.ts` serializes the loaded bundle into the SSR HTML under `window.__I18N__` and rehydrates it client-side. `src/i18n/provider.tsx` wraps React context and re-exports `useTranslation` (the only approved way for feature code to access translations, enforced by 1A-3's `no-restricted-imports` rule on `react-i18next`).
|
||||
|
||||
**Tech Stack:** `i18next@^23`, `react-i18next@^15`, `i18next-icu@^2`, `i18next-resources-to-backend@^1`.
|
||||
|
||||
**Scope boundaries:**
|
||||
- No feature-specific translation namespaces (single `common` namespace for Phase 1).
|
||||
- No date-fns/tz porting — design spec §6.4 date formatting is Phase 2 work.
|
||||
- No `<SeoHead>` — that's 1F-seo.
|
||||
|
||||
**Prerequisites:** 1A-1 + 1A-2 + 1A-3 complete. `src/env/`, `src/host-contract.ts`, ESLint boundaries all in place.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility | Task |
|
||||
|---|---|---|
|
||||
| `src/i18n/resolver.ts` | `Language` type, locale resolution from URL prefix | 2 |
|
||||
| `src/i18n/resolver.test.ts` | Tests for resolver | 2 |
|
||||
| `src/i18n/config.ts` | `createI18nInstance` factory | 3 |
|
||||
| `src/i18n/config.test.ts` | Tests for factory | 3 |
|
||||
| `src/i18n/serializer.ts` | SSR→client hydration helpers | 4 |
|
||||
| `src/i18n/serializer.test.ts` | Roundtrip tests | 4 |
|
||||
| `src/i18n/provider.tsx` | React context + `useTranslation` re-export | 5 |
|
||||
| `src/i18n/locales/{lang}/common.json` (×9) | Ported locale bundles | 1 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Install i18n deps and port locale JSON files
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json`
|
||||
- Create: `src/i18n/locales/ru/common.json` (×9 languages)
|
||||
|
||||
- [ ] **Step 1: Install i18n packages**
|
||||
|
||||
```bash
|
||||
pnpm add i18next@^23.0.0 react-i18next@^15.0.0 i18next-icu@^2.0.0 i18next-resources-to-backend@^1.0.0 intl-messageformat@^10.0.0
|
||||
```
|
||||
|
||||
`intl-messageformat` is a peer dep of `i18next-icu`.
|
||||
|
||||
- [ ] **Step 2: Port locale JSON files**
|
||||
|
||||
Copy each Angular locale file to the new i18n directory. The file structure changes from flat (`ClientApp/src/assets/i18n/ru.json`) to nested (`src/i18n/locales/ru/common.json`) to support future per-feature namespaces.
|
||||
|
||||
```bash
|
||||
mkdir -p src/i18n/locales/{ru,en,es,fr,it,ja,ko,zh,de}
|
||||
for lang in ru en es fr it ja ko zh de; do
|
||||
cp "ClientApp/src/assets/i18n/${lang}.json" "src/i18n/locales/${lang}/common.json"
|
||||
done
|
||||
```
|
||||
|
||||
Verify all 9 files copied:
|
||||
|
||||
```bash
|
||||
ls -la src/i18n/locales/*/common.json | wc -l
|
||||
```
|
||||
Expected: 9.
|
||||
|
||||
**BOM removal.** The Angular files may have a UTF-8 BOM marker (`\xEF\xBB\xBF`). Strip it to avoid JSON parse issues:
|
||||
|
||||
```bash
|
||||
for f in src/i18n/locales/*/common.json; do
|
||||
sed -i '' '1s/^\xef\xbb\xbf//' "$f" 2>/dev/null || sed -i '1s/^\xef\xbb\xbf//' "$f"
|
||||
done
|
||||
```
|
||||
|
||||
Verify they parse:
|
||||
|
||||
```bash
|
||||
for f in src/i18n/locales/*/common.json; do
|
||||
node -e "JSON.parse(require('fs').readFileSync('$f','utf8'))" && echo "OK: $f"
|
||||
done
|
||||
```
|
||||
Expected: 9 "OK" lines.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json pnpm-lock.yaml src/i18n/locales/
|
||||
git commit -m "Install i18n deps and port 9 locale JSON files from Angular"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — TDD `src/i18n/resolver.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/i18n/resolver.test.ts`
|
||||
- Create: `src/i18n/resolver.ts`
|
||||
|
||||
**Contract (from master plan §1C):**
|
||||
```ts
|
||||
export type Language = "ru"|"en"|"es"|"fr"|"it"|"ja"|"ko"|"zh"|"de";
|
||||
export const LANGUAGES: readonly Language[];
|
||||
export function isLanguage(x: string): x is Language;
|
||||
export function resolveLocaleFromPath(pathname: string): Language | null;
|
||||
export function stripLocaleFromPath(pathname: string): { locale: Language; rest: string } | null;
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `src/i18n/resolver.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type Language,
|
||||
LANGUAGES,
|
||||
isLanguage,
|
||||
resolveLocaleFromPath,
|
||||
stripLocaleFromPath,
|
||||
} from "./resolver.js";
|
||||
|
||||
describe("LANGUAGES", () => {
|
||||
it("contains exactly 9 supported languages", () => {
|
||||
expect(LANGUAGES).toHaveLength(9);
|
||||
expect(LANGUAGES).toContain("ru");
|
||||
expect(LANGUAGES).toContain("en");
|
||||
expect(LANGUAGES).toContain("de");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLanguage", () => {
|
||||
it("returns true for valid languages", () => {
|
||||
expect(isLanguage("ru")).toBe(true);
|
||||
expect(isLanguage("en")).toBe(true);
|
||||
expect(isLanguage("zh")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for invalid strings", () => {
|
||||
expect(isLanguage("xx")).toBe(false);
|
||||
expect(isLanguage("")).toBe(false);
|
||||
expect(isLanguage("RU")).toBe(false);
|
||||
expect(isLanguage("russian")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLocaleFromPath", () => {
|
||||
it("extracts locale from the first path segment", () => {
|
||||
expect(resolveLocaleFromPath("/ru/onlineboard")).toBe("ru");
|
||||
expect(resolveLocaleFromPath("/en/onlineboard/flight/SU100")).toBe("en");
|
||||
expect(resolveLocaleFromPath("/de/schedule")).toBe("de");
|
||||
});
|
||||
|
||||
it("returns null for paths without a valid locale prefix", () => {
|
||||
expect(resolveLocaleFromPath("/onlineboard")).toBeNull();
|
||||
expect(resolveLocaleFromPath("/xx/onlineboard")).toBeNull();
|
||||
expect(resolveLocaleFromPath("/")).toBeNull();
|
||||
expect(resolveLocaleFromPath("")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles bare locale path (e.g., /ru)", () => {
|
||||
expect(resolveLocaleFromPath("/ru")).toBe("ru");
|
||||
expect(resolveLocaleFromPath("/ru/")).toBe("ru");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripLocaleFromPath", () => {
|
||||
it("strips locale and returns the rest", () => {
|
||||
expect(stripLocaleFromPath("/ru/onlineboard")).toEqual({
|
||||
locale: "ru",
|
||||
rest: "/onlineboard",
|
||||
});
|
||||
expect(stripLocaleFromPath("/en/onlineboard/flight/SU100")).toEqual({
|
||||
locale: "en",
|
||||
rest: "/onlineboard/flight/SU100",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns / as rest for bare locale path", () => {
|
||||
expect(stripLocaleFromPath("/ru")).toEqual({ locale: "ru", rest: "/" });
|
||||
expect(stripLocaleFromPath("/ru/")).toEqual({ locale: "ru", rest: "/" });
|
||||
});
|
||||
|
||||
it("returns null for paths without a valid locale prefix", () => {
|
||||
expect(stripLocaleFromPath("/onlineboard")).toBeNull();
|
||||
expect(stripLocaleFromPath("/xx/schedule")).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests — MUST FAIL**
|
||||
|
||||
```bash
|
||||
pnpm test src/i18n/resolver
|
||||
```
|
||||
Expected: FAIL — module not found.
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/i18n/resolver.ts`:
|
||||
|
||||
```typescript
|
||||
export type Language = "ru" | "en" | "es" | "fr" | "it" | "ja" | "ko" | "zh" | "de";
|
||||
|
||||
export const LANGUAGES: readonly Language[] = [
|
||||
"ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de",
|
||||
] as const;
|
||||
|
||||
const languageSet: ReadonlySet<string> = new Set(LANGUAGES);
|
||||
|
||||
export function isLanguage(x: string): x is Language {
|
||||
return languageSet.has(x);
|
||||
}
|
||||
|
||||
export function resolveLocaleFromPath(pathname: string): Language | null {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const first = segments[0];
|
||||
if (first !== undefined && isLanguage(first)) {
|
||||
return first;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function stripLocaleFromPath(
|
||||
pathname: string,
|
||||
): { locale: Language; rest: string } | null {
|
||||
const locale = resolveLocaleFromPath(pathname);
|
||||
if (locale === null) return null;
|
||||
|
||||
const rest = pathname.slice(`/${locale}`.length);
|
||||
return {
|
||||
locale,
|
||||
rest: rest === "" || rest === "/" ? "/" : rest,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests — ALL MUST PASS**
|
||||
|
||||
```bash
|
||||
pnpm test src/i18n/resolver
|
||||
```
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 5: Typecheck + lint**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/i18n/resolver.ts src/i18n/resolver.test.ts
|
||||
git commit -m "Add locale resolver with Language type and URL prefix parsing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — TDD `src/i18n/config.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/i18n/config.test.ts`
|
||||
- Create: `src/i18n/config.ts`
|
||||
|
||||
**Contract (from master plan §1C):**
|
||||
```ts
|
||||
export function createI18nInstance(options: {
|
||||
locale: Language;
|
||||
initialResources?: Record<string, Record<string, unknown>>;
|
||||
}): Promise<i18n>;
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `src/i18n/config.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createI18nInstance } from "./config.js";
|
||||
|
||||
describe("createI18nInstance", () => {
|
||||
it("creates an initialized i18next instance for the given locale", async () => {
|
||||
const i18n = await createI18nInstance({ locale: "ru" });
|
||||
expect(i18n.language).toBe("ru");
|
||||
expect(i18n.isInitialized).toBe(true);
|
||||
});
|
||||
|
||||
it("can translate a known key from the Russian locale", async () => {
|
||||
const i18n = await createI18nInstance({ locale: "ru" });
|
||||
const value = i18n.t("BOARD.DEPARTURE");
|
||||
// The Russian file has "BOARD.DEPARTURE" = "Вылет"
|
||||
expect(value).toBe("Вылет");
|
||||
});
|
||||
|
||||
it("can translate a known key from the English locale", async () => {
|
||||
const i18n = await createI18nInstance({ locale: "en" });
|
||||
const value = i18n.t("BOARD.DEPARTURE");
|
||||
expect(value).toBe("Departure");
|
||||
});
|
||||
|
||||
it("uses initialResources instead of loading from filesystem when provided", async () => {
|
||||
const i18n = await createI18nInstance({
|
||||
locale: "ru",
|
||||
initialResources: {
|
||||
common: { TEST_KEY: "Тестовое значение" },
|
||||
},
|
||||
});
|
||||
expect(i18n.t("TEST_KEY")).toBe("Тестовое значение");
|
||||
});
|
||||
|
||||
it("returns the key path if a key is missing (no fallback to other locale)", async () => {
|
||||
const i18n = await createI18nInstance({ locale: "ru" });
|
||||
expect(i18n.t("NONEXISTENT.KEY")).toBe("NONEXISTENT.KEY");
|
||||
});
|
||||
|
||||
it("each call returns a fresh instance (request-scoped)", async () => {
|
||||
const a = await createI18nInstance({ locale: "ru" });
|
||||
const b = await createI18nInstance({ locale: "en" });
|
||||
expect(a).not.toBe(b);
|
||||
expect(a.language).toBe("ru");
|
||||
expect(b.language).toBe("en");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — MUST FAIL**
|
||||
|
||||
```bash
|
||||
pnpm test src/i18n/config
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/i18n/config.ts`:
|
||||
|
||||
```typescript
|
||||
import i18next from "i18next";
|
||||
import ICU from "i18next-icu";
|
||||
import resourcesToBackend from "i18next-resources-to-backend";
|
||||
import type { Language } from "./resolver.js";
|
||||
|
||||
export async function createI18nInstance(options: {
|
||||
locale: Language;
|
||||
initialResources?: Record<string, Record<string, unknown>>;
|
||||
}): Promise<typeof i18next> {
|
||||
const instance = i18next.createInstance();
|
||||
|
||||
const plugins = [ICU];
|
||||
|
||||
// If no pre-loaded resources, load from the locale JSON files on the filesystem.
|
||||
if (!options.initialResources) {
|
||||
plugins.push(
|
||||
resourcesToBackend(
|
||||
(language: string, namespace: string) =>
|
||||
import(`./locales/${language}/${namespace}.json`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const plugin of plugins) {
|
||||
instance.use(plugin);
|
||||
}
|
||||
|
||||
await instance.init({
|
||||
lng: options.locale,
|
||||
ns: ["common"],
|
||||
defaultNS: "common",
|
||||
fallbackLng: false,
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
keySeparator: ".",
|
||||
...(options.initialResources
|
||||
? {
|
||||
resources: {
|
||||
[options.locale]: options.initialResources,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
return instance;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — ALL MUST PASS**
|
||||
|
||||
```bash
|
||||
pnpm test src/i18n/config
|
||||
```
|
||||
|
||||
If tests fail because dynamic `import()` of `.json` files doesn't work in vitest's node environment, try setting `vitest.config.ts` to `environment: "node"` (already set) and ensure `resolveJsonModule: true` is in `tsconfig.json` (already set in 1A-1). If it still fails, use `fs.readFileSync` with `JSON.parse` as a fallback for the backend loader:
|
||||
|
||||
```typescript
|
||||
resourcesToBackend(
|
||||
(language: string, namespace: string) => {
|
||||
const data = require(`./locales/${language}/${namespace}.json`);
|
||||
return Promise.resolve(data);
|
||||
},
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Typecheck + lint**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/i18n/config.ts src/i18n/config.test.ts
|
||||
git commit -m "Add createI18nInstance factory with ICU and resource backend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — TDD `src/i18n/serializer.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/i18n/serializer.test.ts`
|
||||
- Create: `src/i18n/serializer.ts`
|
||||
|
||||
**Contract (from master plan §1C):**
|
||||
```ts
|
||||
export function serializeI18nForHydration(i18n: i18n): string; // emits JSON string
|
||||
export function hydrateI18nFromWindow(): Promise<i18n>; // reads window.__I18N__
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `src/i18n/serializer.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { afterEach, describe, expect, it, vi } 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<string, unknown>).__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<string, unknown>).__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__/);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — MUST FAIL**
|
||||
|
||||
```bash
|
||||
pnpm test src/i18n/serializer
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/i18n/serializer.ts`:
|
||||
|
||||
```typescript
|
||||
import type i18next from "i18next";
|
||||
import { createI18nInstance } from "./config.js";
|
||||
import { isLanguage } from "./resolver.js";
|
||||
|
||||
interface I18nPayload {
|
||||
locale: string;
|
||||
resources: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, Record<string, unknown>> = {};
|
||||
|
||||
for (const ns of i18n.options.ns as string[]) {
|
||||
const bundle = i18n.getResourceBundle(locale, ns) as Record<string, unknown> | 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<typeof i18next> {
|
||||
const raw = (globalThis as Record<string, unknown>).__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,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — ALL MUST PASS**
|
||||
|
||||
```bash
|
||||
pnpm test src/i18n/serializer
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Typecheck + lint**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/i18n/serializer.ts src/i18n/serializer.test.ts
|
||||
git commit -m "Add SSR↔client i18n hydration serializer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 — Create `src/i18n/provider.tsx`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/i18n/provider.tsx`
|
||||
|
||||
**Contract (from master plan §1C):** React Context provider + `<I18nProvider>` component + `useI18n()` accessor. Re-exports `useTranslation` from `react-i18next` so feature code never imports `react-i18next` directly (enforced by 1A-3's ESLint rule).
|
||||
|
||||
This file does NOT get TDD'd — it's a thin React wrapper with no logic, and testing it requires a render environment that 1F-layout will exercise.
|
||||
|
||||
- [ ] **Step 1: Write `src/i18n/provider.tsx`**
|
||||
|
||||
```tsx
|
||||
import { I18nextProvider, useTranslation as useTranslationOriginal } from "react-i18next";
|
||||
import type { ReactNode } from "react";
|
||||
import type i18next from "i18next";
|
||||
|
||||
/**
|
||||
* Wraps the i18next provider. All downstream code accesses translations
|
||||
* through this provider and the re-exported hooks below.
|
||||
*/
|
||||
export function I18nProvider({
|
||||
i18n,
|
||||
children,
|
||||
}: {
|
||||
i18n: typeof i18next;
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-export of react-i18next's useTranslation. Feature code MUST import
|
||||
* from "@/i18n/provider", never from "react-i18next" directly (enforced
|
||||
* by the no-restricted-imports ESLint rule in 1A-3).
|
||||
*/
|
||||
export const useTranslation = useTranslationOriginal;
|
||||
|
||||
/**
|
||||
* Convenience alias for accessing the i18next instance from context.
|
||||
* Same as useTranslation().i18n.
|
||||
*/
|
||||
export function useI18n(): typeof i18next {
|
||||
const { i18n } = useTranslation();
|
||||
return i18n;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Typecheck + lint**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
```
|
||||
Expected: both exit 0. The `no-restricted-imports` rule allows `react-i18next` in `src/i18n/provider.tsx` (it's in the ignores list set up in 1A-3).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/i18n/provider.tsx
|
||||
git commit -m "Add I18nProvider with useTranslation re-export for feature code"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6 — Full exit-gate verification
|
||||
|
||||
- [ ] **Step 1: Quality gates**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint && pnpm test
|
||||
```
|
||||
|
||||
Expected: all pass. Test count should be 21 (from 1A) + resolver tests + config tests + serializer tests = ~33+ total.
|
||||
|
||||
- [ ] **Step 2: Verify resolver→config→serializer roundtrip in one shot**
|
||||
|
||||
```bash
|
||||
node -e "
|
||||
(async () => {
|
||||
const { createI18nInstance } = await import('./src/i18n/config.js');
|
||||
const { serializeI18nForHydration } = await import('./src/i18n/serializer.js');
|
||||
const i18n = await createI18nInstance({ locale: 'ru' });
|
||||
console.log('t(BOARD.DEPARTURE):', i18n.t('BOARD.DEPARTURE'));
|
||||
const json = serializeI18nForHydration(i18n);
|
||||
const payload = JSON.parse(json);
|
||||
console.log('Serialized locale:', payload.locale);
|
||||
console.log('Serialized keys sample:', Object.keys(payload.resources.common).slice(0,5));
|
||||
console.log('ROUNDTRIP OK');
|
||||
})();
|
||||
" 2>&1 || echo "Node roundtrip failed — check module resolution"
|
||||
```
|
||||
|
||||
Expected: prints `t(BOARD.DEPARTURE): Вылет`, locale `ru`, some key names, and `ROUNDTRIP OK`.
|
||||
|
||||
- [ ] **Step 3: Verify git status clean**
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage.** Master plan §1C exports:
|
||||
- `createI18nInstance` factory → Task 3
|
||||
- `Language` type + resolver functions → Task 2
|
||||
- 9 locale JSON files → Task 1
|
||||
- `serializeI18nForHydration` + `hydrateI18nFromWindow` → Task 4
|
||||
- `I18nProvider` + `useTranslation` re-export + `useI18n` → Task 5
|
||||
|
||||
**Placeholder scan.** No TBD/TODO. All code blocks are complete.
|
||||
|
||||
**Type consistency.**
|
||||
- `Language` type defined in resolver.ts, imported by config.ts, serializer.ts. Same shape everywhere.
|
||||
- `createI18nInstance` returns `Promise<typeof i18next>`. Consistent in config.ts, serializer.ts, and tests.
|
||||
- `i18next-icu` plugin loaded in config.ts — matches master plan 1C dependency list.
|
||||
Reference in New Issue
Block a user