diff --git a/app/server/lib/__tests__/accept-language.test.ts b/app/server/lib/__tests__/accept-language.test.ts new file mode 100644 index 00000000..f1f29013 --- /dev/null +++ b/app/server/lib/__tests__/accept-language.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "vitest"; +import { getLocaleFromAcceptLanguage } from "~/server/lib/accept-language"; + +describe("getLocaleFromAcceptLanguage", () => { + test("strips quality values from the preferred language", () => { + expect(getLocaleFromAcceptLanguage("en;q=0.5")).toBe("en"); + }); + + test("skips invalid language tags and uses the next valid one", () => { + expect(getLocaleFromAcceptLanguage("*, en-GB;q=0.8")).toBe("en-GB"); + }); + + test("falls back to en-US when no valid language is present", () => { + expect(getLocaleFromAcceptLanguage(";;;, ???")).toBe("en-US"); + expect(getLocaleFromAcceptLanguage(null)).toBe("en-US"); + }); +}); diff --git a/app/server/lib/accept-language.ts b/app/server/lib/accept-language.ts new file mode 100644 index 00000000..5f0ca86a --- /dev/null +++ b/app/server/lib/accept-language.ts @@ -0,0 +1,24 @@ +const DEFAULT_LOCALE = "en-US"; + +export function getLocaleFromAcceptLanguage(acceptLanguage?: string | null) { + if (!acceptLanguage) { + return DEFAULT_LOCALE; + } + + for (const value of acceptLanguage.split(",")) { + const languageTag = value.split(";")[0]?.trim(); + + if (!languageTag) { + continue; + } + + try { + Intl.getCanonicalLocales(languageTag); + return languageTag; + } catch { + continue; + } + } + + return DEFAULT_LOCALE; +} diff --git a/app/server/lib/functions/root-loader-data.ts b/app/server/lib/functions/root-loader-data.ts index 00f7f599..7f788f6f 100644 --- a/app/server/lib/functions/root-loader-data.ts +++ b/app/server/lib/functions/root-loader-data.ts @@ -2,6 +2,7 @@ import { createServerFn } from "@tanstack/react-start"; import { getCookie, getRequestHeaders } from "@tanstack/react-start/server"; import { THEME_COOKIE_NAME } from "~/client/components/theme-provider"; import type { DateFormatPreference, TimeFormatPreference } from "~/client/lib/datetime"; +import { getLocaleFromAcceptLanguage } from "~/server/lib/accept-language"; import { auth } from "~/server/lib/auth"; export const getRootLoaderData = createServerFn({ method: "GET" }).handler(async () => { @@ -12,7 +13,7 @@ export const getRootLoaderData = createServerFn({ method: "GET" }).handler(async return { theme: (themeCookie === "light" ? "light" : "dark") as "light" | "dark", - locale: (acceptLanguage?.split(",")[0] || "en-US") as string, + locale: getLocaleFromAcceptLanguage(acceptLanguage), timeZone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", dateFormat: (session?.user.dateFormat ?? "MM/DD/YYYY") as DateFormatPreference, timeFormat: (session?.user.timeFormat ?? "12h") as TimeFormatPreference,