i18n: switch URL locale codes to xx-xx (Angular contract)

Angular's LocalizationService reads `Country = baseHref[1..3]` and
`Language = baseHref[4..6]` — both halves are the same 2-letter
language code (`/ru-ru/`, `/en-en/`, `/zh-zh/`, …), confirmed by
the spec fixtures using `/en-en/onlineboard/...`. The previous
shipping codes mixed in IETF region codes (`en-us`, `ja-jp`, `ko-kr`,
`zh-cn`) which do not match the customer's URL surface.

Renamed:
  en-us → en-en
  ja-jp → ja-ja
  ko-kr → ko-ko
  zh-cn → zh-zh

The `LANGUAGE_TO_LOCALE_CODE` table now mirrors Angular exactly.
Resolver/hreflang tests + layout 404 message updated.
This commit is contained in:
2026-04-19 18:39:51 +03:00
parent ce2ca4a689
commit 9acfeb4052
6 changed files with 28 additions and 23 deletions
+4 -4
View File
@@ -33,13 +33,13 @@ describe("isLanguage", () => {
describe("resolveLocaleFromPath", () => {
it("extracts BCP-47 locale from the first path segment", () => {
expect(resolveLocaleFromPath("/ru-ru/onlineboard")).toBe("ru-ru");
expect(resolveLocaleFromPath("/en-us/onlineboard/flight/SU100")).toBe("en-us");
expect(resolveLocaleFromPath("/en-en/onlineboard/flight/SU100")).toBe("en-en");
expect(resolveLocaleFromPath("/de-de/schedule")).toBe("de-de");
});
it("auto-promotes a bare short language code to its BCP-47 cousin", () => {
expect(resolveLocaleFromPath("/ru/onlineboard")).toBe("ru-ru");
expect(resolveLocaleFromPath("/en/onlineboard/flight/SU100")).toBe("en-us");
expect(resolveLocaleFromPath("/en/onlineboard/flight/SU100")).toBe("en-en");
expect(resolveLocaleFromPath("/de/schedule")).toBe("de-de");
});
@@ -63,8 +63,8 @@ describe("stripLocaleFromPath", () => {
locale: "ru-ru",
rest: "/onlineboard",
});
expect(stripLocaleFromPath("/en-us/onlineboard/flight/SU100")).toEqual({
locale: "en-us",
expect(stripLocaleFromPath("/en-en/onlineboard/flight/SU100")).toEqual({
locale: "en-en",
rest: "/onlineboard/flight/SU100",
});
});
+17 -12
View File
@@ -3,7 +3,7 @@
* file lookup, API path segment, and `Accept-Language` header.
*
* Mirrors Angular's `LocalizationService`: URL is BCP-47 (`/ru-ru/`,
* `/en-us/`, `/zh-cn/`...), backend + translation files use the short
* `/en-en/`, `/zh-zh/`...), backend + translation files use the short
* 2-letter language part only (`ru`, `en`, `zh`...). This split keeps
* the customer's URL contract while reusing a single set of locale
* resources.
@@ -13,13 +13,13 @@ export type Language = "ru" | "en" | "es" | "fr" | "it" | "ja" | "ko" | "zh" | "
export type LocaleCode =
| "ru-ru"
| "en-us"
| "en-en"
| "es-es"
| "fr-fr"
| "it-it"
| "ja-jp"
| "ko-kr"
| "zh-cn"
| "ja-ja"
| "ko-ko"
| "zh-zh"
| "de-de";
export const LANGUAGES: readonly Language[] = [
@@ -27,7 +27,7 @@ export const LANGUAGES: readonly Language[] = [
] as const;
export const LOCALE_CODES: readonly LocaleCode[] = [
"ru-ru", "en-us", "es-es", "fr-fr", "it-it", "ja-jp", "ko-kr", "zh-cn", "de-de",
"ru-ru", "en-en", "es-es", "fr-fr", "it-it", "ja-ja", "ko-ko", "zh-zh", "de-de",
] as const;
export const DEFAULT_LOCALE_CODE: LocaleCode = "ru-ru";
@@ -36,15 +36,20 @@ export const DEFAULT_LANGUAGE: Language = "ru";
const languageSet: ReadonlySet<string> = new Set(LANGUAGES);
const localeCodeSet: ReadonlySet<string> = new Set(LOCALE_CODES);
// Angular's URL contract is `/{country}-{language}` where the two
// halves repeat the same 2-letter language code (see
// `ClientApp/src/app/shared/services/localization.service.ts` —
// `Country = baseHref[1..3]`, `Language = baseHref[4..6]`). We mirror
// that exact shape so the React MF remote shares Angular's URL surface.
const LANGUAGE_TO_LOCALE_CODE: Record<Language, LocaleCode> = {
ru: "ru-ru",
en: "en-us",
en: "en-en",
es: "es-es",
fr: "fr-fr",
it: "it-it",
ja: "ja-jp",
ko: "ko-kr",
zh: "zh-cn",
ja: "ja-ja",
ko: "ko-ko",
zh: "zh-zh",
de: "de-de",
};
@@ -58,7 +63,7 @@ export function isLocaleCode(x: string): x is LocaleCode {
/**
* Extract the language part from a BCP-47 locale code.
* `localeToLanguage("en-us")` → `"en"`.
* `localeToLanguage("en-en")` → `"en"`.
*/
export function localeToLanguage(code: LocaleCode): Language {
return code.slice(0, 2) as Language;
@@ -66,7 +71,7 @@ export function localeToLanguage(code: LocaleCode): Language {
/**
* Promote a short language code to its canonical URL locale code.
* `languageToLocale("en")` → `"en-us"`. Used when the URL needs the
* `languageToLocale("en")` → `"en-en"`. Used when the URL needs the
* BCP-47 form but only the short language is in hand.
*/
export function languageToLocale(lang: Language): LocaleCode {
+1 -1
View File
@@ -2,7 +2,7 @@
* `useLocale()` — central hook that resolves the current page's
* locale from `useParams<{lang}>` and exposes both forms:
*
* - `locale` — BCP-47 URL code (`"ru-ru"`, `"en-us"`, …) for
* - `locale` — BCP-47 URL code (`"ru-ru"`, `"en-en"`, …) for
* building outgoing links and reading from
* `window.location`.
* - `language` — short code (`"ru"`, `"en"`, …) for the i18n file
+3 -3
View File
@@ -15,12 +15,12 @@ import type i18next from "i18next";
// Register all PrimeReact locales once at module load. The active
// locale is selected per-render via setPrimeLocale() so that switching
// between /ru-ru and /en-us swaps Calendar/AutoComplete labels too.
// between /ru-ru and /en-en swaps Calendar/AutoComplete labels too.
registerPrimeLocales(addLocale);
/**
* Locale-scoped layout. Validates the `lang` URL segment (BCP-47:
* `ru-ru`, `en-us`, …; legacy short codes like `ru` are auto-promoted
* `ru-ru`, `en-en`, …; legacy short codes like `ru` are auto-promoted
* to their canonical BCP-47 cousin), creates an i18n instance for
* the matching language file, and updates the shared ApiClient's
* locale so backend responses come back in the right language.
@@ -64,7 +64,7 @@ export default function LangLayout(): JSX.Element {
return (
<div>
<h2>404 Unknown locale: {rawLang}</h2>
<p>Supported: ru-ru, en-us, es-es, fr-fr, it-it, ja-jp, ko-kr, zh-cn, de-de</p>
<p>Supported: ru-ru, en-en, es-es, fr-fr, it-it, ja-ja, ko-ko, zh-zh, de-de</p>
</div>
);
}
+2 -2
View File
@@ -43,10 +43,10 @@ describe("buildHreflangSet", () => {
});
const en = result.find((entry) => entry.lang === "en");
expect(en?.href).toBe("https://www.aeroflot.ru/en-us/onlineboard");
expect(en?.href).toBe("https://www.aeroflot.ru/en-en/onlineboard");
const ja = result.find((entry) => entry.lang === "ja");
expect(ja?.href).toBe("https://www.aeroflot.ru/ja-jp/onlineboard");
expect(ja?.href).toBe("https://www.aeroflot.ru/ja-ja/onlineboard");
});
it("preserves paths with nested segments", () => {
+1 -1
View File
@@ -16,7 +16,7 @@ export interface HreflangEntry {
* Returns 9 language entries + 1 x-default entry (pointing to the
* default language). Hreflang attribute uses the short language code
* (`hreflang="en"`); the URL itself uses the BCP-47 locale code
* (`/en-us/...`) to match the customer's URL contract.
* (`/en-en/...`) to match the customer's URL contract.
*/
export function buildHreflangSet(args: {
canonicalOrigin: string;