From 6e54ce1e4264abe70e7776bb0e6d8cbdcf1d7b87 Mon Sep 17 00:00:00 2001 From: Alex de Leeuw Date: Sat, 5 Sep 2026 16:27:25 +0000 Subject: [PATCH] Extract Accept-Language parsing to remove duplication in middleware.ts --- packages/web/package.json | 3 ++- packages/web/src/i18n/locales.ts | 34 ++++++++++++++++++++++++ packages/web/src/middleware.test.ts | 40 +++++++++++++++++++++++++++++ packages/web/src/middleware.ts | 32 ++--------------------- 4 files changed, 78 insertions(+), 31 deletions(-) create mode 100644 packages/web/src/middleware.test.ts diff --git a/packages/web/package.json b/packages/web/package.json index 280a52e6..abb1f7ee 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -9,7 +9,8 @@ "start": "astro dev", "build": "astro build", "preview": "astro preview", - "astro": "astro" + "astro": "astro", + "test": "bun test" }, "dependencies": { "@astrojs/cloudflare": "12.6.3", diff --git a/packages/web/src/i18n/locales.ts b/packages/web/src/i18n/locales.ts index d1b3e74d..7f73f853 100644 --- a/packages/web/src/i18n/locales.ts +++ b/packages/web/src/i18n/locales.ts @@ -115,3 +115,37 @@ export function matchLocale(input: string) { return starts.find((item) => value.startsWith(item[0]))?.[1] ?? null } + +export function parseAcceptLanguage(header: string | null): { lang: string; q: number }[] { + if (!header) return [] + + return header + .split(",") + .map((raw) => raw.trim()) + .filter(Boolean) + .map((raw) => { + const parts = raw.split(";").map((x) => x.trim()) + const lang = parts[0] ?? "" + const q = parts + .slice(1) + .find((x) => x.startsWith("q=")) + ?.slice(2) + return { + lang, + q: q ? Number.parseFloat(q) : 1, + } + }) + .sort((a, b) => b.q - a.q) +} + +export function localeFromAcceptLanguage(header: string | null) { + const items = parseAcceptLanguage(header) + + const locale = items + .map((item) => item.lang) + .filter((lang) => lang && lang !== "*") + .map((lang) => matchLocale(lang)) + .find((lang) => lang) + + return locale ?? "root" +} \ No newline at end of file diff --git a/packages/web/src/middleware.test.ts b/packages/web/src/middleware.test.ts new file mode 100644 index 00000000..4ef61c5e --- /dev/null +++ b/packages/web/src/middleware.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import { parseAcceptLanguage, localeFromAcceptLanguage } from "./i18n/locales" + +describe("parseAcceptLanguage", () => { + test("returns empty array for null header", () => { + expect(parseAcceptLanguage(null)).toEqual([]) + }) + + test("parses a single language with no q value as q=1", () => { + expect(parseAcceptLanguage("fr")).toEqual([{ lang: "fr", q: 1 }]) + }) + + test("parses multiple languages and sorts by q value descending", () => { + const result = parseAcceptLanguage("en;q=0.5,fr;q=0.9,de") + expect(result.map((r) => r.lang)).toEqual(["de", "fr", "en"]) + }) + + test("ignores empty segments from trailing commas", () => { + const result = parseAcceptLanguage("en,,fr") + expect(result.map((r) => r.lang)).toEqual(["en", "fr"]) + }) +}) + +describe("localeFromAcceptLanguage", () => { + test("returns 'root' for null header", () => { + expect(localeFromAcceptLanguage(null)).toBe("root") + }) + + test("picks the highest-weighted matching locale", () => { + expect(localeFromAcceptLanguage("en;q=0.5,fr;q=0.9")).toBe("fr") + }) + + test("returns 'root' when no listed language matches a known locale", () => { + expect(localeFromAcceptLanguage("xx-XX")).toBe("root") + }) + + test("skips wildcard entries", () => { + expect(localeFromAcceptLanguage("*;q=0.9,de;q=0.5")).toBe("de") + }) +}) \ No newline at end of file diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts index cf9f97b0..8671979c 100644 --- a/packages/web/src/middleware.ts +++ b/packages/web/src/middleware.ts @@ -1,5 +1,6 @@ import { defineMiddleware } from "astro:middleware" -import { exactLocale, matchLocale } from "./i18n/locales" +import { exactLocale, matchLocale, localeFromAcceptLanguage } from "./i18n/locales" + function docsAlias(pathname: string) { const hit = /^\/docs\/([^/]+)(\/.*)?$/.exec(pathname) @@ -47,35 +48,6 @@ function localeFromCookie(header: string | null) { return matchLocale(raw) } -function localeFromAcceptLanguage(header: string | null) { - if (!header) return "root" - - const items = header - .split(",") - .map((raw) => raw.trim()) - .filter(Boolean) - .map((raw) => { - const parts = raw.split(";").map((x) => x.trim()) - const lang = parts[0] ?? "" - const q = parts - .slice(1) - .find((x) => x.startsWith("q=")) - ?.slice(2) - return { - lang, - q: q ? Number.parseFloat(q) : 1, - } - }) - .sort((a, b) => b.q - a.q) - - const locale = items - .map((item) => item.lang) - .filter((lang) => lang && lang !== "*") - .map((lang) => matchLocale(lang)) - .find((lang) => lang) - - return locale ?? "root" -} export const onRequest = defineMiddleware((ctx, next) => { const alias = docsAlias(ctx.url.pathname)