# 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.
