plan/react-rewrite #1

Merged
gnezim merged 138 commits from plan/react-rewrite into main 2026-04-15 12:21:16 +03:00
Showing only changes of commit 3067f8f111 - Show all commits
@@ -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.