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..272f0d7f --- /dev/null +++ b/app/server/lib/__tests__/accept-language.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest"; +import { getLocaleFromAcceptLanguage } from "~/server/lib/accept-language"; + +describe("getLocaleFromAcceptLanguage", () => { + test("strips quality values from a single preferred language", () => { + expect(getLocaleFromAcceptLanguage("en;q=0.5")).toBe("en"); + }); + + test("prefers the highest quality language", () => { + expect(getLocaleFromAcceptLanguage("en;q=0.5, fr;q=0.9")).toBe("fr"); + }); + + test("treats language tags without q as the highest priority", () => { + expect(getLocaleFromAcceptLanguage("en;q=0.9, fr-CH")).toBe("fr-CH"); + }); + + 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..0aa7d323 --- /dev/null +++ b/app/server/lib/accept-language.ts @@ -0,0 +1,52 @@ +const DEFAULT_LOCALE = "en-US"; + +export function getLocaleFromAcceptLanguage(acceptLanguage?: string | null) { + if (!acceptLanguage) { + return DEFAULT_LOCALE; + } + + let locale = DEFAULT_LOCALE; + let highestQuality = -1; + + for (const value of acceptLanguage.split(",")) { + const [rawLanguageTag, ...parameters] = value.split(";"); + const languageTag = rawLanguageTag?.trim(); + + if (!languageTag) { + continue; + } + + let quality = 1; + + for (const parameter of parameters) { + const [key, value] = parameter.split("="); + + if (key?.trim().toLowerCase() !== "q") { + continue; + } + + const parsedQuality = Number(value?.trim()); + + if (!Number.isFinite(parsedQuality) || parsedQuality < 0 || parsedQuality > 1) { + quality = -1; + break; + } + + quality = parsedQuality; + break; + } + + if (quality <= highestQuality) { + continue; + } + + try { + locale = Intl.getCanonicalLocales(languageTag)[0] || DEFAULT_LOCALE; + highestQuality = quality; + } catch { + continue; + } + } + + return 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,