Files
flights_web/docs/superpowers/plans/2026-04-14-phase-1c-i18n.md
T
gnezim 3067f8f111 Add Phase 1C i18n runtime implementation plan
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.
2026-04-14 23:13:35 +03:00

20 KiB
Raw Blame History

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

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:

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