Support Angular country-language locales
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user