diff --git a/docs/superpowers/plans/2026-04-14-phase-1c-i18n.md b/docs/superpowers/plans/2026-04-14-phase-1c-i18n.md new file mode 100644 index 00000000..d975dbf3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-1c-i18n.md @@ -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 `` — 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 = 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>; +}): Promise; +``` + +- [ ] **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>; +}): Promise { + 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; // 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).__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__/); + }); +}); +``` + +- [ ] **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>; +} + +/** + * 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> = {}; + + for (const ns of i18n.options.ns as string[]) { + 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, + }); +} +``` + +- [ ] **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 + `` 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 {children}; +} + +/** + * 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`. Consistent in config.ts, serializer.ts, and tests. +- `i18next-icu` plugin loaded in config.ts — matches master plan 1C dependency list.