From bf3087d45ea65b0ff244019bb22b197c37e48723 Mon Sep 17 00:00:00 2001 From: gnezim Date: Tue, 14 Apr 2026 23:17:08 +0300 Subject: [PATCH] Add locale resolver with Language type and URL prefix parsing --- src/i18n/resolver.test.ts | 74 +++++++++++++++++++++++++++++++++++++++ src/i18n/resolver.ts | 33 +++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/i18n/resolver.test.ts create mode 100644 src/i18n/resolver.ts diff --git a/src/i18n/resolver.test.ts b/src/i18n/resolver.test.ts new file mode 100644 index 00000000..27a8f5b2 --- /dev/null +++ b/src/i18n/resolver.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + 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(); + }); +}); diff --git a/src/i18n/resolver.ts b/src/i18n/resolver.ts new file mode 100644 index 00000000..4829bb43 --- /dev/null +++ b/src/i18n/resolver.ts @@ -0,0 +1,33 @@ +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 = 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, + }; +}