Support Angular country-language locales

This commit is contained in:
2026-05-20 20:11:38 +03:00
parent 7fd789e06a
commit 2b47ca799f
3 changed files with 55 additions and 30 deletions
+14 -2
View File
@@ -31,12 +31,18 @@ describe("isLanguage", () => {
});
describe("resolveLocaleFromPath", () => {
it("extracts BCP-47 locale from the first path segment", () => {
it("extracts Angular country-language locale from the first path segment", () => {
expect(resolveLocaleFromPath("/ru-ru/onlineboard")).toBe("ru-ru");
expect(resolveLocaleFromPath("/en-en/onlineboard/flight/SU100")).toBe("en-en");
expect(resolveLocaleFromPath("/de-de/schedule")).toBe("de-de");
});
it("accepts mixed country-language URLs and resolves language from the second part", () => {
expect(resolveLocaleFromPath("/ru-en/onlineboard")).toBe("ru-en");
expect(resolveLocaleFromPath("/ru-de/schedule")).toBe("ru-de");
expect(resolveLocaleFromPath("/ad-fr/onlineboard")).toBe("ad-fr");
});
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-en");
@@ -47,6 +53,8 @@ describe("resolveLocaleFromPath", () => {
expect(resolveLocaleFromPath("/onlineboard")).toBeNull();
expect(resolveLocaleFromPath("/xx/onlineboard")).toBeNull();
expect(resolveLocaleFromPath("/xx-xx/onlineboard")).toBeNull();
expect(resolveLocaleFromPath("/russia-en/onlineboard")).toBeNull();
expect(resolveLocaleFromPath("/ru-eng/onlineboard")).toBeNull();
expect(resolveLocaleFromPath("/")).toBeNull();
expect(resolveLocaleFromPath("")).toBeNull();
});
@@ -58,7 +66,7 @@ describe("resolveLocaleFromPath", () => {
});
describe("stripLocaleFromPath", () => {
it("strips BCP-47 locale and returns the rest", () => {
it("strips Angular country-language locale and returns the rest", () => {
expect(stripLocaleFromPath("/ru-ru/onlineboard")).toEqual({
locale: "ru-ru",
rest: "/onlineboard",
@@ -67,6 +75,10 @@ describe("stripLocaleFromPath", () => {
locale: "en-en",
rest: "/onlineboard/flight/SU100",
});
expect(stripLocaleFromPath("/ru-en/onlineboard")).toEqual({
locale: "ru-en",
rest: "/onlineboard",
});
});
it("auto-promotes short codes when stripping", () => {
+18 -28
View File
@@ -2,25 +2,16 @@
* Locale codes used in URLs vs the short language code used for i18n
* file lookup, API path segment, and `Accept-Language` header.
*
* Mirrors Angular's `LocalizationService`: URL is BCP-47 (`/ru-ru/`,
* `/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
* Mirrors Angular's `LocalizationService`: URL is `/{country}-{language}`
* (`/ru-ru/`, `/ru-en/`, `/ru-de/`...), while backend + translation files
* use the second 2-letter segment only (`ru`, `en`, `de`...). This split
* keeps the customer's URL contract while reusing a single set of locale
* resources.
*/
export type Language = "ru" | "en" | "es" | "fr" | "it" | "ja" | "ko" | "zh" | "de";
export type LocaleCode =
| "ru-ru"
| "en-en"
| "es-es"
| "fr-fr"
| "it-it"
| "ja-ja"
| "ko-ko"
| "zh-zh"
| "de-de";
export type LocaleCode = `${string}-${Language}`;
export const LANGUAGES: readonly Language[] = [
"ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de",
@@ -34,13 +25,11 @@ export const DEFAULT_LOCALE_CODE: LocaleCode = "ru-ru";
export const DEFAULT_LANGUAGE: Language = "ru";
const languageSet: ReadonlySet<string> = new Set(LANGUAGES);
const localeCodeSet: ReadonlySet<string> = new Set(LOCALE_CODES);
const URL_LOCALE_PATTERN = /^([a-z]{2})-([a-z]{2})$/;
// 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.
// `languageToLocale` returns the canonical same-language URL used for
// generated links, while `normalizeLocaleParam` below also accepts Angular's
// mixed country-language URLs such as `/ru-en`.
const LANGUAGE_TO_LOCALE_CODE: Record<Language, LocaleCode> = {
ru: "ru-ru",
en: "en-en",
@@ -58,15 +47,16 @@ export function isLanguage(x: string): x is Language {
}
export function isLocaleCode(x: string): x is LocaleCode {
return localeCodeSet.has(x);
const match = URL_LOCALE_PATTERN.exec(x);
return match ? isLanguage(match[2] ?? "") : false;
}
/**
* Extract the language part from a BCP-47 locale code.
* `localeToLanguage("en-en")` → `"en"`.
* Extract the language part from an Angular URL locale code.
* `localeToLanguage("ru-en")` → `"en"`.
*/
export function localeToLanguage(code: LocaleCode): Language {
return code.slice(0, 2) as Language;
return code.split("-")[1] as Language;
}
/**
@@ -79,10 +69,10 @@ export function languageToLocale(lang: Language): LocaleCode {
}
/**
* Read the locale code from `:lang` URL params. Accepts both BCP-47
* (`ru-ru`) and bare short codes (`ru`) — the short form is promoted
* to its canonical BCP-47 cousin so legacy / direct API consumers
* keep working during migration.
* Read the locale code from `:lang` URL params. Accepts Angular
* country-language URLs (`ru-en`) and bare short codes (`en`) — the short
* form is promoted to its canonical same-language URL (`en-en`) so legacy /
* direct API consumers keep working during migration.
*/
export function normalizeLocaleParam(raw: string | undefined): LocaleCode | null {
if (!raw) return null;
+23
View File
@@ -50,6 +50,29 @@ test.describe("Smoke tests", () => {
await expect(page.getByText("en-en", { exact: true })).toBeVisible();
});
test("/ru-en/onlineboard uses English language with Russia country prefix", async ({
page,
consoleMessages,
}) => {
await page.goto("/ru-en/onlineboard?_preferredLanguage=en&_preferredLocale=ruh");
await page.waitForLoadState("domcontentloaded");
await expect(page).toHaveURL(/\/ru-en\/onlineboard/);
await expect(page.locator("h1")).toHaveText("Online Timetable", {
timeout: 10000,
});
await expect(page.getByTestId("standalone-header")).toContainText("Services", {
timeout: 15000,
});
await expect(page.getByTestId("standalone-header")).toContainText("EN", {
timeout: 15000,
});
await expect(page.locator('afl-component[data-component="Footer"]')).toContainText(
"Contact us",
{ timeout: 15000 },
);
});
test("/xx/smoke shows 404 or unknown locale message", async ({ page, consoleMessages }) => {
await page.goto("/xx/smoke");
await page.waitForLoadState("domcontentloaded");