6 tasks: port 9 locale JSONs, TDD resolver with Language type, TDD createI18nInstance factory with ICU, TDD SSR↔client hydration serializer, I18nProvider with useTranslation re-export.
20 KiB
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
commonnamespace 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
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.
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:
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:
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:
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
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):
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:
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
pnpm test src/i18n/resolver
Expected: FAIL — module not found.
- Step 3: Write implementation
Create src/i18n/resolver.ts:
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
pnpm test src/i18n/resolver
Expected: all tests pass.
- Step 5: Typecheck + lint
pnpm typecheck && pnpm lint
- Step 6: Commit
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):
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:
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
pnpm test src/i18n/config
- Step 3: Write implementation
Create src/i18n/config.ts:
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
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:
resourcesToBackend(
(language: string, namespace: string) => {
const data = require(`./locales/${language}/${namespace}.json`);
return Promise.resolve(data);
},
),
- Step 5: Typecheck + lint
pnpm typecheck && pnpm lint
- Step 6: Commit
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):
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:
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
pnpm test src/i18n/serializer
- Step 3: Write implementation
Create src/i18n/serializer.ts:
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
pnpm test src/i18n/serializer
- Step 5: Typecheck + lint
pnpm typecheck && pnpm lint
- Step 6: Commit
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
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
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
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
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
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
git status
Self-review
Spec coverage. Master plan §1C exports:
createI18nInstancefactory → Task 3Languagetype + resolver functions → Task 2- 9 locale JSON files → Task 1
serializeI18nForHydration+hydrateI18nFromWindow→ Task 4I18nProvider+useTranslationre-export +useI18n→ Task 5
Placeholder scan. No TBD/TODO. All code blocks are complete.
Type consistency.
Languagetype defined in resolver.ts, imported by config.ts, serializer.ts. Same shape everywhere.createI18nInstancereturnsPromise<typeof i18next>. Consistent in config.ts, serializer.ts, and tests.i18next-icuplugin loaded in config.ts — matches master plan 1C dependency list.